Bank loans and leases, from the contract to every entry
+
+ A separate free app, Loans and Leases, next to this one under
+ Accounting: the loans your company receives and the leases it holds, with their schedule and every entry
+ posted for you. Install it when you need it.
+
+
✓ An amortization schedule in equal instalments or fixed principal, monthly to annual, or imported from the schedule of the lender.
+
✓ Drawdowns in tranches, instalments paid from the schedule, early repayment for a shorter term or lower instalments.
+
✓ A fixed or floating rate, with dated rate changes that compute the unpaid instalments again.
+
✓ Leases: the liability at the present value of the payments, and the vendor bill of the lessor on each due date.
+
✓ The principal due within 12 months reclassified to the short term, by hand or every month.
+
✓ Loans and Leases at any date, on screen and in PDF, and the Loans Analysis pivot and graph.
✓ With the cash forecast (in this app): the instalments and lease payments on their due dates, through the bridge Loans and Leases in the Cash Forecast. Take the latest version of this app first.
+
+
+
+
The amortization schedule of a loan, in the Loans and Leases app.
DashboardsNew in 20.0
diff --git a/om_account_accountant/static/description/loans_schedule.png b/om_account_accountant/static/description/loans_schedule.png
new file mode 100644
index 000000000..4996c429c
Binary files /dev/null and b/om_account_accountant/static/description/loans_schedule.png differ
diff --git a/om_account_accountant/static/description/odoomates_apps.gif b/om_account_accountant/static/description/odoomates_apps.gif
index 94d19a413..c0b65d0b5 100644
Binary files a/om_account_accountant/static/description/odoomates_apps.gif and b/om_account_accountant/static/description/odoomates_apps.gif differ
diff --git a/om_account_accountant/static/description/om_app_loans_and_leases.png b/om_account_accountant/static/description/om_app_loans_and_leases.png
new file mode 100644
index 000000000..515c3b038
Binary files /dev/null and b/om_account_accountant/static/description/om_app_loans_and_leases.png differ
diff --git a/om_account_cash_forecast/__manifest__.py b/om_account_cash_forecast/__manifest__.py
index de8ce0db4..38098afc8 100644
--- a/om_account_cash_forecast/__manifest__.py
+++ b/om_account_cash_forecast/__manifest__.py
@@ -1,6 +1,6 @@
{
'name': 'Odoo 20 Cash Forecast',
- 'version': '1.0.1',
+ 'version': '1.0.2',
'category': 'Accounting',
'summary': 'Forecast the cash balance by week or month from the invoices, bills and expected payments',
'description': """
diff --git a/om_account_cash_forecast/report/report_cash_forecast.py b/om_account_cash_forecast/report/report_cash_forecast.py
index 43c636902..1afc6addd 100644
--- a/om_account_cash_forecast/report/report_cash_forecast.py
+++ b/om_account_cash_forecast/report/report_cash_forecast.py
@@ -12,6 +12,15 @@ class ReportCashForecast(models.AbstractModel):
_name = 'report.om_account_cash_forecast.report_cash_forecast'
_description = 'Cash Forecast Report'
+ @api.model
+ def _get_row_keys(self):
+ """ The rows of the receipts and of the payments, in their order. Other modules add theirs, with their
+ label in `_get_row_labels`.
+
+ :return: (receipt row keys, payment row keys)
+ """
+ return list(RECEIPT_ROWS), list(PAYMENT_ROWS)
+
@api.model
def _get_row_labels(self):
return {
@@ -170,9 +179,10 @@ def column_of(date):
opening = currency.round(opening)
labels = self._get_row_labels()
+ receipt_rows, payment_rows = self._get_row_keys()
sections = []
- for key, name, row_keys in (('receipts', _('Receipts'), RECEIPT_ROWS),
- ('payments', _('Payments'), PAYMENT_ROWS)):
+ for key, name, row_keys in (('receipts', _('Receipts'), receipt_rows),
+ ('payments', _('Payments'), payment_rows)):
rows = []
for row_key in row_keys:
if row_key not in amounts:
diff --git a/om_account_loan/README.rst b/om_account_loan/README.rst
new file mode 100644
index 000000000..b0ca2d5ad
--- /dev/null
+++ b/om_account_loan/README.rst
@@ -0,0 +1,417 @@
+================
+Loans and Leases
+================
+
+This module manages the loans a company receives from banks and other lenders,
+and the leases it holds, in Odoo 20 Community Edition: the contract, its
+amortization schedule, the drawdowns, the instalments, the early repayments,
+the floating rates, the lease liability and the bills of the lessor, the
+withholding tax on the interest, the schedules imported from the lender, the
+current and non-current reclassification and a loan report.
+
+Features
+========
+
+* Record a loan: the lender and its reference, the principal, the currency,
+ the start date, the first payment date, the number of payments and their
+ frequency (monthly, quarterly, semi-annual or annual), a fixed or floating
+ annual rate, the accounts and the journals. Loans are numbered LOAN/year/0001
+ and move from Draft to Running, then Closed (or Cancelled).
+* Three schedule methods: equal instalments (annuity), fixed principal, or
+ the schedule of the lender imported from a CSV or XLSX file.
+ 100,000 at 6% over 12 monthly payments gives 12 instalments of 8,606.64,
+ the last one taking the rounding residue (8,606.69) so that the principal is
+ repaid exactly.
+* Two interest methods: the annual rate divided by the number of payments a
+ year (a shorter or longer period is counted in days), or the actual days of
+ the period over 365.
+* The due dates follow the calendar months: a first payment on 15 January
+ gives 15 February, 15 March...; a first payment on the last day of a month
+ keeps the last day of the months (31 January, 28 or 29 February, 31 March).
+* The unpaid lines of the schedule can be changed by hand (due date,
+ principal, interest): the balances follow and the last line repays what is
+ left. Compute Schedule computes them again from the principal drawn and
+ keeps the paid lines.
+* Draw the loan down in one or more tranches; each drawdown is posted and the
+ unpaid lines are computed again from the principal actually drawn.
+* Pay an instalment from its line: the principal, the interest and the
+ payment date can be adjusted to what the bank charged.
+* Repay early, in part or in full, with the interest due up to the repayment
+ date, then choose a shorter term or lower instalments.
+* Floating rates: the rate changes on dated rate changes, and the unpaid
+ lines from that date are computed again.
+* Reclassify at each period end the principal due within 12 months from the
+ long-term to the short-term loan account.
+* The Loan Report gives per loan, at a date, the outstanding principal, the
+ interest accrued since the last due date, the next due date and amount, and
+ the principal due within 12 months and after, as a list, a pivot or a PDF.
+ Each loan prints with its schedule.
+* Loans in a foreign currency.
+* Leases: the lease liability at the present value of the lease payments,
+ recognized against the right-of-use asset, and each payment billed by the
+ lessor on its due date (see Leases below).
+* A withholding tax on the interest, where the law asks for one: deducted
+ from what is paid to the lender and booked on the account of the tax (see
+ The withholding tax below).
+* Record rules per company; the accountants (Show Full Accounting Features)
+ and the accounting administrators run the loans, the administrators alone
+ delete them and set the defaults, the auditors read them, the invoicing
+ users do not see them.
+* The schedule protects itself: a paid instalment, a posted operation or an
+ entry of the loan is changed by cancelling it on the loan, not from
+ Accounting nor through the API; the lender and the loan accounts are fixed
+ once the loan has entries, its terms once it runs.
+* Lock dates: an entry dated in a locked period is refused, with the lock
+ date, rather than posted on a later date than its schedule.
+
+The journal entries
+===================
+
+Each action posts one entry, with the lender as partner:
+
+===================== ================== ==========================================
+Action Journal Entry
+===================== ================== ==========================================
+Drawdown Bank journal Dr Outstanding Receipts / Cr Long-term loan
+Instalment Bank journal Dr Short-term loan, then Long-term loan
+ (principal) + Dr Interest expense /
+ Cr Outstanding Payments
+Early repayment Bank journal as an instalment, with the interest due
+Reclassification Loan journal Dr Long-term loan / Cr Short-term loan
+ (or the reverse)
+===================== ================== ==========================================
+
+For example, 100,000 at 6% over 12 months drawn on 1 January 2025:
+
+* the drawdown posts Dr Outstanding Receipts 100,000.00 / Cr Long-term loan
+ 100,000.00;
+* the first instalment, on 1 February, posts Dr Long-term loan 8,106.64 and
+ Dr Interest expense 500.00 / Cr Outstanding Payments 8,606.64;
+* a reclassification on 31 January posts Dr Long-term loan 100,000.00 /
+ Cr Short-term loan 100,000.00, the whole principal being due within 12
+ months; the instalments then repay the short-term account first.
+
+The bank side of the entries
+----------------------------
+
+In Odoo 20 the bank account of a bank journal is fed by the bank statement
+lines. An entry posted straight on it would add to the bank balance a second
+time when the statement line comes in, or be left beside it unreconciled. So
+the drawdowns and the payments do what the payments of Odoo do: their bank
+leg goes to the outstanding receipts or payments account of the bank journal
+(the account of its payment methods, else the outstanding account of the chart
+of accounts). When the bank statement line comes in, it is matched with that
+outstanding line like a customer or vendor payment, and the outstanding
+account goes back to zero.
+
+A company that does not import its bank statements, or uses a cash journal
+without statements, can set Post Bank Leg On to Bank Account on the loan: the
+bank leg then goes straight to the account of the journal.
+
+How the schedule follows the loan
+=================================
+
+The schedule is computed again, keeping the paid lines, after each event:
+
+* Before the first drawdown, the schedule assumes the whole principal is
+ drawn on the start date: it is a projection.
+* After a drawdown, the unpaid lines repay the principal drawn: the period of
+ a tranche counts the old balance up to the drawdown and the new one after it,
+ and a new instalment (or principal per period) is computed from the new
+ balance over the payments left. The opening balance of a line is the
+ principal owed on its due date, the tranches of the period included.
+* An early repayment pays the interest due up to its date, so the next line
+ only counts the interest from the repayment on. Lower Instalment keeps the
+ number of payments and lowers the instalments; Shorter Term keeps them about
+ the same and reduces the number of payments to what the next instalment (or
+ principal per period) needs to repay the balance. A full repayment closes the
+ loan.
+* A rate change computes again the unpaid lines due from its date: those
+ after it with the rate per period (a period takes the rate in force on its
+ first day), those on or after it with the actual days, which split a period
+ at the change. It is dated after the last paid instalment.
+* A payment is dated on or after the start of the loan and the payment of the
+ instalment before it. An instalment is paid once the loan is drawn down.
+* Changing the rate or the number of payments of a running loan computes the
+ unpaid lines again.
+
+Drawdowns and early repayments are dated after the last paid instalment, and
+the instalments due on or before an early repayment are paid first. The last
+paid instalment and the drawdowns and repayments (latest first) can be
+cancelled from the loan: their entry is reversed on its date and the schedule
+computed again. These entries cannot be reset to draft or reversed from the
+journal entry itself.
+
+The reclassification
+--------------------
+
+At a period end, Accounting > Loans > Reclassification posts for each loan the
+move that leaves on the short-term account the principal not paid at that date
+and due within the next 12 months. What the earlier reclassifications and the
+instalments already moved is taken into account, so the reclassification can
+be run at every month or year end: it posts only the difference, in either
+direction. Reclassify in the order of the dates.
+
+Leases
+======
+
+A lease is a contract of the Lease type (Accounting > Loans > Leases),
+numbered LEASE/year/0001. Enter the lessor, the lease payment (taxes
+excluded), the number of payments, their frequency, the first payment date
+(the start date for a lease paid in advance) and the rate of the lease (its
+implicit rate, or the incremental borrowing rate of the company). The lease
+liability is computed: it is the present value of the payments at that rate,
+discounted period by period as the schedule counts the interest, so that the
+payments repay it exactly. A lease has a fixed rate and equal payments.
+
+The schedule is the one of the loans: each payment is split into the interest
+(the finance charge, on the interest expense account) and the principal (the
+reduction of the lease liability); the last payment takes the rounding.
+
+The entries of a lease
+----------------------
+
+===================== ================== ==========================================
+Action Journal Entry
+===================== ================== ==========================================
+Confirmation Loan journal Dr Right-of-use asset / Cr Long-term
+ lease liability, at the present value
+Lease payment Purchase journal the vendor bill of the lessor: Dr
+ Short-term, then Long-term lease
+ liability (principal) + Dr Interest
+ expense (+ taxes) / Cr Payable
+Bill payment Bank journal as any vendor bill
+Reclassification Loan journal as for the loans
+===================== ================== ==========================================
+
+For example, 12 monthly payments of 1,000.00 in arrears at 6% from
+1 January 2025:
+
+* the lease liability is 11,618.93; the confirmation posts on 1 January
+ Dr Right-of-use asset 11,618.93 / Cr Long-term lease liability 11,618.93;
+* the first payment, due on 1 February, is a bill of the lessor of 1,000.00:
+ Dr Long-term lease liability 941.91 and Dr Interest expense 58.09 /
+ Cr Payable 1,000.00. The next one repays 946.61 with 53.39 of interest,
+ the last one 995.02 with 4.98;
+* with the VAT of 7% on the bills and a withholding tax of 1% on the
+ interest, the first bill adds Dr VAT 70.00 and Cr Withholding tax 0.58:
+ Cr Payable 1,069.42.
+
+Paid in advance (the first payment on the start date), the same lease is
+11,677.03: the first payment repays 1,000.00 of liability without interest.
+
+The bills
+---------
+
+The Bill button of a lease payment (or Create Bills on the selected
+instalments) creates the vendor bill of the lessor, dated on the due date, in
+the purchase journal of the lease. A daily scheduled action creates the bills
+of the payments due, for the leases set to Bill on the Due Dates. The bills
+are posted, or left in draft to check (Lease Bills on the lease); the taxes on
+the bills (such as the VAT) go on all the lines, the withholding tax on the
+interest line. The bill is then paid like any vendor bill.
+
+In the localizations whose purchase journals use documents (Latin America),
+the bill is left in draft for the number of the lessor's document, whatever
+the Lease Bills setting: enter it and post the bill.
+
+The amounts come from the schedule: change the line before billing it. A
+draft bill keeps the lessor, the currency and the amount before taxes of its
+lease payment: posting it refuses a bill changed otherwise (the taxes may
+change). The bills are in the order of the schedule. The last bill can be cancelled from the
+lease, when it is not paid: a posted bill is reversed by a credit note, a draft
+one is cancelled. Post the draft bills before reclassifying.
+
+The right-of-use asset is debited on the account of the lease (Right-of-use
+Asset Account, from the settings). With Assets Management (om_account_asset)
+installed, the module Leases: Right-of-use Assets creates the asset itself
+(see below).
+
+A lease is not drawn down nor repaid early, and its terms do not change once
+recognized. If its recognition is cancelled (Operations tab), Recognize posts
+it again.
+
+The withholding tax
+===================
+
+Some countries make the borrower withhold a tax on the interest it pays and
+pay it to the tax office, e.g. 1% of the interest. Set this tax as the
+Withholding Tax of the loan or lease: a tax of the company with a negative
+amount (-1%), its distribution giving the account of the tax withheld (and
+the tax grids). Then:
+
+* the schedule shows on each line the tax withheld on its interest and the
+ net payment, the PDF of the loan too, and the Loan Report the tax withheld up
+ to its date;
+* the payment of an instalment, and of the interest of an early repayment,
+ posts Dr Loan + Dr Interest / Cr Withholding tax + Cr Outstanding payments
+ for the rest. The withholding line is a tax line of the tax (its account,
+ its grids, the interest as its base), and the interest line has the base
+ grids, so that the tax reports include it. The tax withheld by the bank can
+ be changed when paying;
+* the bill of a lease payment has the tax on its interest line: Odoo computes
+ it as the other taxes of the bill, and it reduces the amount payable.
+
+For example, the first instalment of the loan above withholds 5.00:
+Dr Long-term loan 8,106.64 and Dr Interest expense 500.00 / Cr Withholding tax
+5.00 and Cr Outstanding Payments 8,601.64.
+
+The tax is computed by the taxes of Odoo. When a localization defers it to
+the payments (the Withholding option of the Withholding Tax module of Odoo,
+used by some charts of accounts), the bills leave it to the Pay button of the
+bill, as for any bill, and the schedule and the report of the lease do not
+count it as withheld; the payments of the loans withhold it themselves.
+
+Imported schedules
+==================
+
+When the lender gives its own schedule, choose the Imported schedule method
+and click Import Schedule. The file is a CSV (comma, semicolon or tab) or an
+XLSX file, one row per instalment, a first row of titles being skipped:
+
+* Date, Principal, Interest; or
+* Date, Instalment, Interest (the principal is the instalment less the
+ interest).
+
+For example, the first rows of the schedule of the loan above::
+
+ Date,Principal,Interest
+ 2025-02-01,8106.64,500.00
+ 2025-03-01,8147.17,459.47
+ 2025-04-01,8187.91,418.73
+
+The dates are 2025-02-01, 01/02/2025 (day first) or in the format of the language of the
+user; the amounts 1234.56, 1,234.56 or 1234,56. The import checks the file:
+dates increasing, after the start of the loan and its paid instalments,
+amounts that are numbers, no negative interest or principal, and principal
+adding up to what the loan has to repay (its principal, less the paid
+instalments and the early repayments). The unpaid lines are replaced; the
+paid ones stay.
+
+The imported lines are then paid, changed by hand and printed as the others.
+They are kept as imported when the schedule is computed again: the principal
+not drawn yet is assumed drawn on the start date, and an early repayment
+comes off the last lines (the loan ends earlier). Import the new schedule of
+the lender after an early repayment or a rate change, if it changes the
+interest.
+
+Multi-currency
+==============
+
+A loan may be in another currency than the company. Its entries are posted in
+the currency of the loan with the value in the company currency at the rate of
+the entry date. The bank journal must be in the currency of the loan or in the
+company currency.
+
+The liability stays at its carrying rate, the rate of the drawdowns (of the
+recognition of a lease). The principal repaid at another rate realizes an
+exchange difference: each instalment, early repayment, lease bill or
+reclassification that repays principal posts an Exchange Difference entry on
+its date, between the loan account and the exchange gain or loss account of
+the company (in the exchange journal of the accounting settings). A loan
+repaid in full is back to zero in the company currency as well. Cancelling the
+repayment reverses its exchange difference. The unrealized difference on the
+outstanding principal at a period end is the work of a currency revaluation.
+
+Installation
+============
+
+To install this module, you need to:
+
+Download the module and add it to your Odoo addons folder. Afterward, log on to
+your Odoo server and go to the Apps menu. Trigger the debug mode and update the
+list by clicking on the "Update Apps List" link. Now install the module by
+clicking on the install button.
+
+Upgrade
+=======
+
+To upgrade this module, you need to:
+
+Download the module and add it to your Odoo addons folder. Restart the server
+and log on to your Odoo server. Select the Apps menu and upgrade the module by
+clicking on the upgrade button.
+
+Configuration
+=============
+
+In Accounting > Configuration > Settings, in the Loans section, set the
+accounts and the journals proposed on new loans: the long-term loan account (a
+non-current liability), the short-term loan account (a current liability),
+the interest expense account, the loan journal for the reclassifications and
+the bank journal for the drawdowns and payments.
+
+For the leases, set the long-term and short-term lease liability accounts
+(the loan accounts when empty), the right-of-use asset account and the
+purchase journal of the bills.
+
+Tick Reclassify the Loans Monthly to have the running loans and leases
+reclassified at each month end without running the wizard: at the beginning
+of each month, a scheduled action reclassifies them at the end of the month
+before, unless they are reclassified on that date already. A loan that cannot
+be reclassified (a locked period, a lease bill left in draft) says why in its
+chatter, once, and is reclassified the next day if it can.
+
+For a withholding tax, create the tax in Accounting > Configuration > Taxes:
+Tax Type Purchases (or None), a negative amount (-1%), the account of the tax
+withheld on its distribution.
+
+Check that the payment methods of the bank journal have their outstanding
+receipts and payments accounts (Accounting > Configuration > Journals, the
+Incoming and Outgoing Payments tabs).
+
+Usage
+=====
+
+* Accounting > Loans > Loans: create the loan, check the schedule computed on
+ confirmation, then click Draw Down for each tranche.
+* Pay the instalments with the Pay button of the next line, or from
+ Accounting > Loans > Instalments, which lists the instalments due within 30
+ days of every loan, by due date (remove the filter for all of them).
+* Accounting > Loans > Leases: create the lease and confirm it, which
+ recognizes it; bill the payments with the Bill button of their line, or let
+ the scheduled action bill them on their due dates; pay the bills as any
+ vendor bill.
+* Early Repayment on the loan repays part or all of the principal: enter
+ the principal repaid (all of it closes the loan).
+* For a floating rate, add the rate changes in the Rate Changes tab.
+* At each period end, run Accounting > Loans > Reclassification (or tick
+ Reclassify the Loans Monthly in the settings).
+* Reporting > Management > Loans and Leases shows the position of the loans
+ and of the leases at a date (or of one type), and prints it. The amounts are
+ in the currency of each loan, the totals in the company currency (the
+ outstanding at its value in the books).
+* Reporting > Management > Loans Analysis shows the principal, the interest and
+ the instalments of the running and closed loans and leases year by year, as
+ a pivot table or a graph (the schedule lines, paid or to pay).
+
+The Accounting menu of Invoicing, where the Loans are, shows to the users with
+Show Full Accounting Features (Settings > Users, in developer mode), as for the
+other accounting menus; Odoo 20 Accounting Community (om_account_accountant)
+gives it to every accountant.
+
+Right-of-use assets
+===================
+
+With Assets Management (om_account_asset), the module Leases: Right-of-use
+Assets (om_account_loan_asset) is installed automatically. Choose a
+Right-of-use Asset Category on the lease: on its recognition, the asset
+account of the category is debited and the right-of-use asset is created in
+that category, at the lease liability (in the company currency), from the
+start of the lease, depreciated linearly to the end of the lease term with the
+journal, the accounts and the periods of the category, and confirmed when the
+category confirms its assets. The asset shows on the lease; cancelling the
+recognition cancels it.
+
+Credits
+=======
+
+Contributors
+------------
+
+* Odoo Mates
+
+Author & Maintainer
+-------------------
+
+This module is maintained by the Odoo Mates
diff --git a/om_account_loan/__init__.py b/om_account_loan/__init__.py
new file mode 100644
index 000000000..50235cbb1
--- /dev/null
+++ b/om_account_loan/__init__.py
@@ -0,0 +1,3 @@
+from . import models
+from . import wizard
+from . import report
diff --git a/om_account_loan/__manifest__.py b/om_account_loan/__manifest__.py
new file mode 100644
index 000000000..1632cfaee
--- /dev/null
+++ b/om_account_loan/__manifest__.py
@@ -0,0 +1,40 @@
+{
+ 'name': 'Loans and Leases',
+ 'version': '1.0.0',
+ 'category': 'Accounting',
+ 'summary': 'Bank loans and leases: amortization schedule, drawdowns in tranches, instalments, early '
+ 'repayment, floating rates, lease liability and lessor bills, withholding tax on the interest, '
+ 'imported schedules, current and non-current reclassification, loan report',
+ 'description': 'Loans received from banks and other lenders, and leases, with their amortization schedule '
+ 'and entries.',
+ 'author': 'Odoo Mates',
+ 'maintainer': 'Odoo Mates',
+ 'support': 'odoomates@gmail.com',
+ 'license': 'LGPL-3',
+ 'images': ['static/description/banner.gif'],
+ 'depends': ['account'],
+ 'data': [
+ 'security/ir.access.csv',
+ 'data/ir_sequence_data.xml',
+ 'data/ir_cron_data.xml',
+ 'views/account_loan_views.xml',
+ 'views/account_move_views.xml',
+ 'views/res_config_settings_views.xml',
+ 'wizard/account_loan_drawdown_views.xml',
+ 'wizard/account_loan_pay_views.xml',
+ 'wizard/account_loan_repay_views.xml',
+ 'wizard/account_loan_reclassify_views.xml',
+ 'wizard/account_loan_import_views.xml',
+ 'report/account_loan_report_views.xml',
+ 'report/report_loan_templates.xml',
+ 'report/account_loan_analysis_views.xml',
+ 'views/menu.xml',
+ ],
+ 'demo': ['demo/account_loan_demo.xml'],
+ 'assets': {
+ 'web.assets_tests': [
+ 'om_account_loan/static/tests/tours/**/*',
+ ],
+ },
+ 'application': False,
+}
diff --git a/om_account_loan/data/ir_cron_data.xml b/om_account_loan/data/ir_cron_data.xml
new file mode 100644
index 000000000..a9acf49d0
--- /dev/null
+++ b/om_account_loan/data/ir_cron_data.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ Leases: bill the lease payments due
+
+ code
+ model._cron_create_lease_bills()
+
+ 1
+ days
+
+
+
+ Loans: reclassify at the month end
+
+ code
+ model._cron_reclassify()
+
+ 1
+ days
+
+
+
+ Create Bills
+
+
+ list
+
+ code
+ action = records.action_create_bills()
+
+
+
diff --git a/om_account_loan/data/ir_sequence_data.xml b/om_account_loan/data/ir_sequence_data.xml
new file mode 100644
index 000000000..a91d4d08b
--- /dev/null
+++ b/om_account_loan/data/ir_sequence_data.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Loans
+ account.loan
+ LOAN/%(year)s/
+ 4
+
+
+
+
+
+ Leases
+ account.loan.lease
+ LEASE/%(year)s/
+ 4
+
+
+
+
+
diff --git a/om_account_loan/demo/account_loan_demo.xml b/om_account_loan/demo/account_loan_demo.xml
new file mode 100644
index 000000000..361f21f6b
--- /dev/null
+++ b/om_account_loan/demo/account_loan_demo.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/om_account_loan/models/__init__.py b/om_account_loan/models/__init__.py
new file mode 100644
index 000000000..61414a3f1
--- /dev/null
+++ b/om_account_loan/models/__init__.py
@@ -0,0 +1,7 @@
+from . import account_loan
+from . import account_loan_line
+from . import account_loan_operation
+from . import account_loan_rate
+from . import account_move
+from . import res_company
+from . import res_config_settings
diff --git a/om_account_loan/models/account_loan.py b/om_account_loan/models/account_loan.py
new file mode 100644
index 000000000..0d5c8ae36
--- /dev/null
+++ b/om_account_loan/models/account_loan.py
@@ -0,0 +1,1347 @@
+import calendar
+import logging
+import math
+from datetime import timedelta
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.fields import Command, Domain
+from odoo.tools.misc import format_date
+
+from .loan_context import LOAN_WRITE, NO_REGENERATE, SCHEDULE_UPDATE, internal, internal_context
+
+PAYMENT_FREQUENCIES = [
+ ('monthly', 'Monthly'),
+ ('quarterly', 'Quarterly'),
+ ('semiannual', 'Semi-annual'),
+ ('annual', 'Annual'),
+]
+PAYMENTS_PER_YEAR = {'monthly': 12, 'quarterly': 4, 'semiannual': 2, 'annual': 1}
+LOAN_TYPES = [('loan', 'Loan'), ('lease', 'Lease')]
+ONE_DAY = timedelta(days=1)
+# the terms of the contract, fixed once the loan runs
+# the rate and the number of payments too: a new rate is a dated rate change, a new term an early repayment
+TERM_FIELDS = {'principal', 'currency_id', 'company_id', 'date_start', 'payment_frequency', 'date_first_payment',
+ 'schedule_method', 'interest_method', 'loan_type', 'lease_payment', 'account_rou_id', 'rate',
+ 'rate_type', 'payment_count'}
+# what the entries of the loan are booked on, fixed once it has entries
+ENTRY_FIELDS = {'partner_id', 'account_long_term_id', 'account_short_term_id'}
+
+_logger = logging.getLogger(__name__)
+
+
+def annuity(balance, period_rate, count):
+ """ The equal instalment repaying `balance` with its interest in `count` periods at `period_rate`. """
+ if count <= 0:
+ return balance
+ if not period_rate:
+ return balance / count
+ return balance * period_rate / (1 - (1 + period_rate) ** -count)
+
+
+def annuity_count(balance, period_rate, instalment):
+ """ The number of instalments of `instalment` repaying `balance` at `period_rate`, None if they never do. """
+ if instalment <= 0:
+ return None
+ if not period_rate:
+ return math.ceil(balance / instalment - 1e-9)
+ if instalment <= balance * period_rate:
+ return None
+ return math.ceil(-math.log(1 - balance * period_rate / instalment) / math.log(1 + period_rate) - 1e-9)
+
+
+def add_months(day, months, end_of_month=False):
+ """ `day` moved by `months` calendar months, on the last day of the month when `end_of_month`. """
+ result = day + relativedelta(months=months)
+ return result + relativedelta(day=31) if end_of_month else result
+
+
+class AccountLoan(models.Model):
+ _name = 'account.loan'
+ _description = 'Loan'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'date_start desc, id desc'
+ _check_company_auto = True
+
+ name = fields.Char(string='Reference', required=True, readonly=True, copy=False, default='/', tracking=True)
+ loan_type = fields.Selection(
+ LOAN_TYPES, string='Type', required=True, tracking=True,
+ # not a default_ key of the context, which the lines of the schedule would take too
+ default=lambda self: self.env.context.get('loan_default_type') or 'loan',
+ help="Loan: money received from a lender, drawn down and repaid from the bank.\n"
+ "Lease: an asset used under a lease, recognized as a lease liability at the present value of the "
+ "lease payments; each payment is a vendor bill of the lessor.")
+ partner_id = fields.Many2one(
+ 'res.partner', string='Lender', required=True, tracking=True, check_company=True,
+ help="The bank or the person lending the money, or the lessor of a lease.")
+ lender_ref = fields.Char(string='Lender Reference', tracking=True, help="The number of the contract at the lender.")
+ company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company, tracking=True)
+ currency_id = fields.Many2one(
+ 'res.currency', string='Currency', required=True, tracking=True,
+ default=lambda self: self.env.company.currency_id)
+ company_currency_id = fields.Many2one(related='company_id.currency_id', string='Company Currency')
+ principal = fields.Monetary(
+ string='Principal', required=True, tracking=True,
+ compute='_compute_principal', store=True, readonly=False, precompute=True,
+ help="The amount of the loan: it is drawn in one or more drawdowns. For a lease, the lease liability: the "
+ "present value of the lease payments at the rate of the lease.")
+ lease_payment = fields.Monetary(
+ string='Lease Payment', tracking=True,
+ help="The amount of each payment of the lease, taxes excluded. The schedule splits it into the interest "
+ "(the finance charge) and the reduction of the lease liability.")
+ date_start = fields.Date(
+ string='Start Date', required=True, default=fields.Date.context_today, tracking=True,
+ help="The date of the contract. Until the first drawdown, the schedule assumes the principal is drawn on it.")
+ payment_frequency = fields.Selection(
+ PAYMENT_FREQUENCIES, string='Payment Frequency', required=True, default='monthly', tracking=True)
+ date_first_payment = fields.Date(
+ string='First Payment Date', required=True, tracking=True,
+ compute='_compute_date_first_payment', store=True, readonly=False, precompute=True,
+ help="The due date of the first instalment; the next ones fall on the same day of the month, or on the "
+ "last day of the month when the first one does.")
+ payment_count = fields.Integer(
+ string='Number of Payments', required=True, default=12, tracking=True,
+ help="The number of instalments of the loan. Repaying early with a shorter term reduces it.")
+ rate_type = fields.Selection(
+ [('fixed', 'Fixed'), ('floating', 'Floating')], string='Rate Type', required=True, default='fixed',
+ tracking=True, help="Fixed: the rate of the contract, changed only by a rate change agreed with the lender.\n"
+ "Floating: the rate follows an index, with a rate change at each new rate.")
+ rate = fields.Float(
+ string='Annual Rate (%)', digits=(16, 4), tracking=True,
+ help="The annual interest rate of the contract; from the date of a rate change, the rate of the change.")
+ rate_line_ids = fields.One2many('account.loan.rate', 'loan_id', string='Rate Changes', copy=True)
+ reclassification_move_ids = fields.One2many(
+ 'account.move', 'loan_id', string='Reclassifications', readonly=True,
+ domain=[('loan_entry_type', '=', 'reclassification')])
+ current_rate = fields.Float(
+ string='Current Rate (%)', digits=(16, 4), compute='_compute_current_rate',
+ help="The annual rate in force today.")
+ schedule_method = fields.Selection(
+ [('annuity', 'Equal Instalments (Annuity)'), ('fixed_principal', 'Fixed Principal'),
+ ('imported', 'Imported')],
+ string='Schedule Method', required=True, default='annuity', tracking=True,
+ help="Equal instalments: the same amount every period, interest first.\n"
+ "Fixed principal: the same principal every period, with the interest on the balance.\n"
+ "Imported: the schedule given by the lender, imported from a file.")
+ interest_method = fields.Selection(
+ [('period', 'Rate / Payments per Year'), ('actual_365', 'Actual Days / 365')],
+ string='Interest Method', required=True, default='period', tracking=True,
+ help="Rate / Payments per Year: the interest of a whole period is the balance x the annual rate / the "
+ "number of payments per year; a shorter period is counted in days.\n"
+ "Actual Days / 365: the interest is the balance x the annual rate x the days of the period / 365.")
+ account_long_term_id = fields.Many2one(
+ 'account.account', string='Long-term Loan Account', check_company=True, tracking=True,
+ compute='_compute_accounts', store=True, readonly=False, precompute=True,
+ help="The liability credited by the drawdowns (the initial recognition of a lease): the principal due in "
+ "more than 12 months.")
+ account_short_term_id = fields.Many2one(
+ 'account.account', string='Short-term Loan Account', check_company=True, tracking=True,
+ compute='_compute_accounts', store=True, readonly=False, precompute=True,
+ help="The liability for the principal due within 12 months, filled by the reclassification.")
+ account_interest_id = fields.Many2one(
+ 'account.account', string='Interest Expense Account', check_company=True, tracking=True,
+ default=lambda self: self.env.company.loan_interest_account_id)
+ account_rou_id = fields.Many2one(
+ 'account.account', string='Right-of-use Asset Account', check_company=True, tracking=True,
+ default=lambda self: self.env.company.lease_rou_account_id,
+ help="The account debited by the initial recognition of a lease, against the lease liability.")
+ journal_id = fields.Many2one(
+ 'account.journal', string='Loan Journal', check_company=True, tracking=True,
+ domain="[('type', '=', 'general')]",
+ default=lambda self: self.env.company.loan_journal_id,
+ help="The miscellaneous journal of the reclassifications, and of the initial recognition of a lease.")
+ bank_journal_id = fields.Many2one(
+ 'account.journal', string='Bank Journal', check_company=True, tracking=True,
+ domain="[('type', 'in', ('bank', 'cash'))]",
+ default=lambda self: self.env.company.loan_bank_journal_id,
+ help="The journal of the drawdowns and of the payments, unless another one is chosen when posting them.")
+ bank_posting = fields.Selection(
+ [('outstanding', 'Outstanding Receipts / Payments'), ('bank', 'Bank Account')],
+ string='Post Bank Leg On', required=True, default='outstanding', tracking=True,
+ help="Outstanding Receipts / Payments: the bank leg of the entries goes to the outstanding account of the "
+ "bank journal, and is matched with the bank statement line when it comes in.\n"
+ "Bank Account: the bank leg goes straight to the bank account of the journal, without statement "
+ "reconciliation.")
+ bill_journal_id = fields.Many2one(
+ 'account.journal', string='Bill Journal', check_company=True, tracking=True,
+ domain="[('type', '=', 'purchase')]",
+ default=lambda self: self.env.company.lease_bill_journal_id,
+ help="The purchase journal of the bills of the lessor.")
+ bill_posting = fields.Selection(
+ [('posted', 'Posted'), ('draft', 'Draft')], string='Lease Bills', required=True, default='posted',
+ tracking=True, help="Posted: the bills of the lease payments are posted when created.\n"
+ "Draft: they are left in draft, to check and post.")
+ bill_auto_create = fields.Boolean(
+ string='Bill on the Due Dates', default=True, tracking=True,
+ help="Create the bill of each lease payment on its due date, by a daily scheduled action.")
+ bill_tax_ids = fields.Many2many(
+ 'account.tax', 'account_loan_bill_tax_rel', 'loan_id', 'tax_id', string='Taxes on the Bills', tracking=True,
+ check_company=True, domain="[('type_tax_use', '=', 'purchase')]",
+ help="The taxes of the lease payments, such as the VAT, on the lines of the bills.")
+ withholding_tax_id = fields.Many2one(
+ 'account.tax', string='Withholding Tax', check_company=True, tracking=True,
+ domain="[('type_tax_use', 'in', ('purchase', 'none')), ('amount', '<', 0)]",
+ help="The tax the borrower withholds on the interest, where the law asks for it: it is deducted from what "
+ "is paid to the lender and booked on the account of the tax. A tax with a negative amount, "
+ "e.g. -1%.")
+ state = fields.Selection(
+ [('draft', 'Draft'), ('running', 'Running'), ('closed', 'Closed'), ('cancel', 'Cancelled')],
+ string='Status', required=True, default='draft', readonly=True, copy=False, tracking=True)
+ line_ids = fields.One2many('account.loan.line', 'loan_id', string='Schedule', copy=False)
+ operation_ids = fields.One2many('account.loan.operation', 'loan_id', string='Operations', copy=False)
+ move_ids = fields.One2many('account.move', 'loan_id', string='Journal Entries', readonly=True, copy=False)
+ move_count = fields.Integer(compute='_compute_move_count', string='Entry Count')
+ drawn_amount = fields.Monetary(string='Drawn', compute='_compute_amounts', store=True)
+ undrawn_amount = fields.Monetary(string='Undrawn', compute='_compute_amounts', store=True)
+ outstanding_amount = fields.Monetary(
+ string='Outstanding Principal', compute='_compute_amounts', store=True,
+ help="The principal drawn and not repaid yet.")
+ interest_total = fields.Monetary(string='Total Interest', compute='_compute_totals')
+ next_line_id = fields.Many2one('account.loan.line', string='Next Instalment', compute='_compute_totals')
+ next_date = fields.Date(string='Next Due Date', compute='_compute_totals')
+ next_amount = fields.Monetary(string='Next Instalment Amount', compute='_compute_totals')
+ date_end = fields.Date(string='Maturity', compute='_compute_totals')
+ lease_end = fields.Date(
+ string='End of Lease Term', compute='_compute_lease_end',
+ help="The end of the last period of the lease: its last due date, or one period after it when the lease "
+ "is paid in advance.")
+ withholding_total = fields.Monetary(string='Total Withholding Tax', compute='_compute_totals')
+ note = fields.Html(string='Notes')
+ reclassify_failed_on = fields.Date(
+ readonly=True, copy=False, help="The last month end the monthly reclassification failed on.")
+
+ # zero while a draft is filled in (a lease before its payment): checked when the loan is confirmed
+ _positive_principal = models.Constraint('CHECK(principal >= 0)', 'The principal of a loan cannot be negative.')
+ _positive_count = models.Constraint('CHECK(payment_count > 0)', 'A loan has at least one payment.')
+ _positive_rate = models.Constraint('CHECK(rate >= 0)', 'The interest rate cannot be negative.')
+
+ # ------------------------------------------------------------
+ # Computes and constraints
+ # ------------------------------------------------------------
+
+ @api.depends('date_start', 'payment_frequency')
+ def _compute_date_first_payment(self):
+ for loan in self:
+ if loan.date_start and loan.payment_frequency:
+ loan.date_first_payment = add_months(loan.date_start, loan._months_per_period())
+ else:
+ loan.date_first_payment = loan.date_first_payment or False
+
+ @api.depends('loan_type', 'lease_payment', 'rate', 'payment_count', 'payment_frequency', 'date_start',
+ 'date_first_payment', 'interest_method')
+ def _compute_principal(self):
+ for loan in self:
+ if loan.loan_type == 'lease' and loan.state == 'draft':
+ loan.principal = loan._lease_present_value()
+ else:
+ loan.principal = loan.principal
+
+ @api.depends('loan_type', 'company_id')
+ def _compute_accounts(self):
+ for loan in self:
+ company = loan.company_id or self.env.company
+ if loan.loan_type == 'lease':
+ loan.account_long_term_id = company.lease_long_term_account_id or company.loan_long_term_account_id
+ loan.account_short_term_id = company.lease_short_term_account_id or company.loan_short_term_account_id
+ else:
+ loan.account_long_term_id = company.loan_long_term_account_id
+ loan.account_short_term_id = company.loan_short_term_account_id
+
+ @api.depends('loan_type', 'date_start', 'date_first_payment', 'payment_frequency', 'payment_count',
+ 'line_ids.date')
+ def _compute_lease_end(self):
+ for loan in self:
+ loan.lease_end = loan.loan_type == 'lease' and loan._lease_term_end()
+
+ @api.depends('rate', 'rate_type', 'rate_line_ids.date', 'rate_line_ids.rate')
+ @api.depends_context('date')
+ def _compute_current_rate(self):
+ today = fields.Date.context_today(self)
+ for loan in self:
+ loan.current_rate = loan._rate_at(today)
+
+ @api.depends('move_ids')
+ def _compute_move_count(self):
+ for loan in self:
+ loan.move_count = len(loan.move_ids)
+
+ @api.depends('principal', 'operation_ids.state', 'operation_ids.amount', 'operation_ids.operation_type',
+ 'line_ids.state', 'line_ids.principal')
+ def _compute_amounts(self):
+ for loan in self:
+ operations = loan.operation_ids.filtered(lambda op: op.state == 'posted')
+ drawn = sum(operations.filtered(lambda op: op.operation_type != 'repayment').mapped('amount'))
+ repaid = sum(operations.filtered(lambda op: op.operation_type == 'repayment').mapped('amount'))
+ repaid += sum(loan.line_ids.filtered(lambda line: line.state == 'posted').mapped('principal'))
+ loan.drawn_amount = drawn
+ loan.undrawn_amount = loan.principal - drawn
+ loan.outstanding_amount = drawn - repaid
+
+ @api.depends('line_ids.state', 'line_ids.interest', 'line_ids.date', 'line_ids.amount', 'line_ids.withholding')
+ def _compute_totals(self):
+ for loan in self:
+ lines = loan.line_ids.sorted('sequence')
+ following = lines.filtered(lambda line: line.state == 'draft')[:1]
+ loan.interest_total = sum(lines.mapped('interest'))
+ loan.withholding_total = sum(lines.mapped('withholding'))
+ loan.next_line_id = following
+ loan.next_date = following.date
+ loan.next_amount = following.amount
+ loan.date_end = lines[-1:].date
+
+ @api.constrains('date_start', 'date_first_payment', 'loan_type')
+ def _check_dates(self):
+ for loan in self:
+ if loan.loan_type == 'lease':
+ if loan.date_first_payment < loan.date_start:
+ raise ValidationError(self.env._(
+ 'The first payment of the lease %s cannot be due before its start date.', loan.display_name))
+ elif loan.date_first_payment <= loan.date_start:
+ raise ValidationError(self.env._(
+ 'The first payment of the loan %s must be due after its start date.', loan.display_name))
+
+ @api.constrains('loan_type', 'rate_type', 'schedule_method', 'lease_payment')
+ def _check_lease_terms(self):
+ for loan in self.filtered(lambda loan: loan.loan_type == 'lease'):
+ if loan.rate_type != 'fixed' or loan.schedule_method != 'annuity':
+ raise ValidationError(self.env._(
+ 'The lease %s has a fixed rate and equal payments.', loan.display_name))
+ if loan.currency_id.compare_amounts(loan.lease_payment, 0.0) < 0:
+ raise ValidationError(self.env._('The payment of the lease %s cannot be negative.', loan.display_name))
+
+ @api.constrains('withholding_tax_id')
+ def _check_withholding_tax(self):
+ for loan in self:
+ tax = loan.withholding_tax_id
+ if tax and (tax.amount_type not in ('percent', 'fixed') or tax.amount >= 0):
+ raise ValidationError(self.env._(
+ 'The withholding tax %(tax)s of %(loan)s must be a percentage or a fixed amount, negative: it '
+ 'reduces what is paid to the lender.', tax=tax.display_name, loan=loan.display_name))
+
+ @api.constrains('account_long_term_id', 'account_short_term_id', 'account_interest_id')
+ def _check_accounts(self):
+ for loan in self:
+ if loan.account_long_term_id and loan.account_long_term_id == loan.account_short_term_id:
+ raise ValidationError(self.env._(
+ 'The long-term and short-term accounts of the loan %s must differ.', loan.display_name))
+ for account in loan.account_long_term_id | loan.account_short_term_id:
+ if account.account_type in ('asset_receivable', 'liability_payable'):
+ raise ValidationError(self.env._(
+ 'The account %(account)s is a receivable or payable account: the loan %(loan)s needs '
+ 'a liability account of its own.', account=account.display_name, loan=loan.display_name))
+
+ @api.constrains('currency_id', 'bank_journal_id')
+ def _check_bank_journal_currency(self):
+ for loan in self:
+ journal = loan.bank_journal_id
+ if journal.currency_id and journal.currency_id != loan.currency_id:
+ raise ValidationError(self.env._(
+ 'The bank journal %(journal)s is in %(journal_currency)s: the loan %(loan)s is in %(currency)s.',
+ journal=journal.display_name, journal_currency=journal.currency_id.name,
+ loan=loan.display_name, currency=loan.currency_id.name))
+
+ # ------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', '/') == '/':
+ company = self.env['res.company'].browse(vals.get('company_id')) or self.env.company
+ day = vals.get('date_start') or fields.Date.context_today(self)
+ code = 'account.loan.lease' if vals.get('loan_type') == 'lease' else 'account.loan'
+ vals['name'] = self.env['ir.sequence'].with_company(company).with_context(
+ ir_sequence_date=day).next_by_code(code) or '/'
+ return super().create(vals_list)
+
+ def write(self, vals):
+ own = internal(self.env, NO_REGENERATE)
+ if TERM_FIELDS & set(vals) and not own and any(
+ loan.state != 'draft' or loan._has_entries() for loan in self):
+ raise UserError(self.env._(
+ 'The terms of a running loan cannot change: use an early repayment or a rate change, or cancel the '
+ 'loan and reset it to draft while it has no journal entries.'))
+ if ENTRY_FIELDS & set(vals) and any(loan._has_entries() for loan in self):
+ raise UserError(self.env._(
+ 'The lender and the loan accounts of a loan with journal entries cannot change: its entries and '
+ 'its balances are on them.'))
+ if 'move_ids' in vals and not internal(self.env, LOAN_WRITE):
+ raise UserError(self.env._('The journal entries of a loan are linked by the loan itself.'))
+ res = super().write(vals)
+ if {'rate', 'rate_type', 'payment_count'} & set(vals) and not internal(self.env, NO_REGENERATE):
+ self.filtered(lambda loan: loan.state == 'running')._generate_schedule()
+ return res
+
+ def _creation_message(self):
+ self.ensure_one()
+ if self.loan_type == 'lease':
+ return self.env._('Lease created')
+ return self.env._('Loan created')
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_entries(self):
+ for loan in self:
+ if loan.state not in ('draft', 'cancel') or loan._has_entries():
+ raise UserError(self.env._('Only draft or cancelled loans without entries can be deleted.'))
+
+ # ------------------------------------------------------------
+ # Actions
+ # ------------------------------------------------------------
+
+ @api.onchange('loan_type')
+ def _onchange_loan_type(self):
+ if self.loan_type == 'lease':
+ self.rate_type = 'fixed'
+ self.schedule_method = 'annuity'
+
+ def action_confirm(self):
+ """ Run the loan and compute its schedule; a lease is recognized on its start date. """
+ for loan in self:
+ if loan.state != 'draft':
+ raise UserError(self.env._('Only a draft loan can be confirmed.'))
+ loan._check_configuration()
+ loan.state = 'running'
+ if loan.loan_type == 'lease':
+ loan._recognize_lease()
+ loan._generate_schedule()
+
+ def action_recognize(self):
+ """ Recognize again a lease whose initial recognition was cancelled. """
+ for loan in self:
+ if loan.loan_type != 'lease' or loan.state != 'running' or loan._recognition():
+ raise UserError(self.env._('The lease %s is recognized already.', loan.display_name))
+ loan._recognize_lease()
+ loan._generate_schedule()
+
+ def action_compute_schedule(self):
+ for loan in self:
+ if loan.state not in ('draft', 'running'):
+ raise UserError(self.env._('The schedule of a closed or cancelled loan cannot be computed.'))
+ loan._generate_schedule()
+
+ def action_cancel(self):
+ for loan in self:
+ if loan._has_entries():
+ raise UserError(self.env._(
+ 'The loan %s has journal entries: cancel them on the loan first.', loan.display_name))
+ self.write({'state': 'cancel'})
+
+ def action_draft(self):
+ for loan in self:
+ if loan.state != 'cancel':
+ raise UserError(self.env._('Only a cancelled loan can be reset to draft.'))
+ self.line_ids.unlink()
+ self.write({'state': 'draft'})
+
+ def action_drawdown(self):
+ return self._open_wizard('account.loan.drawdown', self.env._('Drawdown'))
+
+ def action_import_schedule(self):
+ self.ensure_one()
+ if self.state not in ('draft', 'running') or self.schedule_method != 'imported':
+ raise UserError(self.env._('Choose the Imported schedule method on a draft or running loan first.'))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Import Schedule'),
+ 'res_model': 'account.loan.import',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_loan_id': self.id},
+ }
+
+ def action_repay(self):
+ return self._open_wizard('account.loan.repay', self.env._('Early Repayment'))
+
+ def action_view_moves(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Journal Entries'),
+ 'res_model': 'account.move',
+ 'view_mode': 'list,form',
+ 'domain': [('loan_id', '=', self.id)],
+ 'context': {'create': False, 'default_loan_id': self.id},
+ }
+
+ def _open_wizard(self, model, name):
+ self.ensure_one()
+ if self.state != 'running':
+ raise UserError(self.env._('The loan %s is not running.', self.display_name))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': name,
+ 'res_model': model,
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_loan_id': self.id},
+ }
+
+ # ------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------
+
+ def _has_entries(self):
+ """ Whether the loan has posted entries that are not cancelled by a reversal: an operation or a payment
+ cancelled leaves its entry and the reversal of it, which cancel out. """
+ self.ensure_one()
+ posted = self.move_ids.filtered(lambda move: move.state == 'posted')
+ reversed_pairs = posted.filtered(lambda move: move.reversed_entry_id in posted)
+ reversed_pairs |= reversed_pairs.reversed_entry_id
+ return bool(posted - reversed_pairs)
+
+ def _check_configuration(self):
+ self.ensure_one()
+ required = ['account_long_term_id', 'account_short_term_id', 'account_interest_id', 'journal_id']
+ required += ['bill_journal_id', 'lease_payment'] if self.loan_type == 'lease' else ['bank_journal_id']
+ missing = [self._fields[fname].string for fname in required if not self[fname]]
+ if self.loan_type == 'lease' and not self._rou_account():
+ missing.append(self._fields['account_rou_id'].string)
+ if missing:
+ raise UserError(self.env._(
+ 'Set these fields on the loan %(loan)s first: %(fields)s.',
+ loan=self.display_name, fields=', '.join(missing)))
+ if self.currency_id.compare_amounts(self.principal, 0.0) <= 0:
+ if self.loan_type == 'lease':
+ raise UserError(self.env._(
+ 'The lease %s has no liability: enter its lease payment, its number of payments and its rate.',
+ self.display_name))
+ raise UserError(self.env._('Enter the principal of the loan %s.', self.display_name))
+ if self.schedule_method == 'imported' and not self.line_ids:
+ raise UserError(self.env._('Import the schedule of the loan %s first.', self.display_name))
+
+ def _payments_per_year(self):
+ return PAYMENTS_PER_YEAR[self.payment_frequency]
+
+ def _months_per_period(self):
+ return 12 // self._payments_per_year()
+
+ def _due_date(self, index):
+ """ The due date of the instalment `index` (0 for the first one), in calendar months from the first
+ payment: 31 January gives 28 or 29 February, then 31 March. """
+ first = self.date_first_payment
+ end_of_month = first.day == calendar.monthrange(first.year, first.month)[1]
+ return add_months(first, index * self._months_per_period(), end_of_month)
+
+ def _rate_at(self, day):
+ """ The annual rate (%) in force on `day`. """
+ self.ensure_one()
+ rate = self.rate
+ for change in self.rate_line_ids.sorted('date'):
+ if change.date <= day:
+ rate = change.rate
+ return rate
+
+ def _principal_events(self, projection=True):
+ """ The changes of the principal other than the instalments, by date: the drawdowns and the initial
+ recognition of a lease (positive) and the early repayments (negative, they settle the interest due on
+ their date).
+
+ :param projection: before the first drawdown, the principal is assumed drawn on the start date; an
+ imported schedule assumes the principal not drawn yet is drawn on the start date
+ :return: list of (date, amount, settles the interest)
+ """
+ self.ensure_one()
+ operations = self.operation_ids.filtered(lambda op: op.state == 'posted').sorted(lambda op: (op.date, op.id))
+ events = [
+ (op.date, -op.amount if op.operation_type == 'repayment' else op.amount, op.operation_type == 'repayment')
+ for op in operations
+ ]
+ if projection:
+ draws = operations.filtered(lambda op: op.operation_type != 'repayment')
+ if not draws:
+ events.insert(0, (self.date_start, self.principal, False))
+ elif self.schedule_method == 'imported':
+ undrawn = self.currency_id.round(self.principal - sum(draws.mapped('amount')))
+ if self.currency_id.compare_amounts(undrawn, 0.0) > 0:
+ events.insert(0, (self.date_start, undrawn, False))
+ return events
+
+ def _period_factor(self, start, end, index, rate):
+ """ The interest of one unit of balance from the end of `start` to the end of `end` in the period of
+ the instalment `index`, at the annual `rate` (%), as `_interest_for` counts it. """
+ days = max((end - start).days, 0)
+ if self.interest_method == 'actual_365':
+ return rate / 100 * days / 365
+ return rate / 100 / self._payments_per_year() * days / self._period_days(index)
+
+ def _lease_present_value(self):
+ """ The present value of the lease payments at the rate of the lease, discounted as the schedule
+ counts the interest: the schedule then repays it with the lease payments exactly, but for the rounding.
+ A payment due on the start date (a lease paid in advance) is not discounted. """
+ self.ensure_one()
+ if not (self.lease_payment and self.payment_count > 0 and self.date_start and self.date_first_payment
+ and self.payment_frequency):
+ return 0.0
+ balance = 0.0
+ for index in reversed(range(self.payment_count)):
+ start = self._due_date(index - 1) if index else self.date_start
+ balance = (balance + self.lease_payment) / (
+ 1 + self._period_factor(start, self._due_date(index), index, self.rate))
+ return self.currency_id.round(balance)
+
+ def _lease_term_end(self):
+ """ The end of the lease term: its last due date, or one period after it when paid in advance. """
+ self.ensure_one()
+ if not (self.date_first_payment and self.date_start and self.payment_frequency and self.payment_count > 0):
+ return False
+ last = self.line_ids.sorted('sequence')[-1:].date or self._due_date(self.payment_count - 1)
+ if self.date_first_payment == self.date_start:
+ return add_months(last, self._months_per_period())
+ return last
+
+ def _period_days(self, index):
+ """ The days of the whole period of the instalment `index`. """
+ return (self._due_date(index) - self._due_date(index - 1)).days
+
+ def _interest_for(self, start, end, balance, events, period_rate, period_days):
+ """ The interest from the end of `start` to the end of `end`, not rounded, on `balance` changed by
+ `events` dated in (start, end]. A drawdown accrues from the day after it, an early repayment settles
+ the interest due up to its date. A floating rate changes during the period with the actual days method;
+ the other method takes `period_rate`, the rate at the start of the period.
+
+ :return: (interest, balance at the end)
+ """
+ cuts = {day for day, _amount, _settles in events}
+ if self.interest_method == 'actual_365':
+ # a rate changing on a day applies from that day: the segment before it ends the day before
+ cuts |= {change.date - ONE_DAY for change in self.rate_line_ids if start < change.date - ONE_DAY < end}
+ interest = 0.0
+ day = start
+ for cut in [*sorted(cut for cut in cuts if start < cut < end), end]:
+ if cut > day:
+ days = (cut - day).days
+ if self.interest_method == 'actual_365':
+ interest += balance * self._rate_at(day + ONE_DAY) / 100 * days / 365
+ else:
+ interest += balance * period_rate / 100 / self._payments_per_year() * days / period_days
+ day = cut
+ for event_day, amount, settles in events:
+ if event_day == cut:
+ balance += amount
+ if settles:
+ interest = 0.0
+ return interest, balance
+
+ # ------------------------------------------------------------
+ # The schedule
+ # ------------------------------------------------------------
+
+ def _kept_lines(self, date_from=None):
+ """ The lines kept by a regeneration: the paid ones, and the unpaid ones due before `date_from`. """
+ kept = self.env['account.loan.line']
+ for line in self.line_ids.sorted('sequence'):
+ if line.state != 'posted' and (not date_from or line.date >= date_from):
+ break
+ kept |= line
+ return kept
+
+ def _schedule_start(self, kept, events):
+ """ :return: (the date the interest of the next line runs from, the balance on that date) """
+ if kept:
+ start = kept[-1].date
+ elif events:
+ start = min(day for day, _amount, _settles in events)
+ else:
+ raise UserError(self.env._('Nothing is drawn on the loan %s.', self.display_name))
+ balance = sum(amount for day, amount, _settles in events if day <= start) - sum(kept.mapped('principal'))
+ return start, self.currency_id.round(balance)
+
+ def _compute_schedule_values(self, kept):
+ """ The values of the lines following `kept`, from the principal drawn and repaid: a new instalment
+ (or principal per period) is computed from the balance when it changes other than by the instalments,
+ or when the rate changes. The last line repays what is left, rounding included.
+
+ :return: list of dicts with date, opening_balance, principal, interest, rate
+ """
+ self.ensure_one()
+ if self.schedule_method == 'imported':
+ return self._imported_schedule_values(kept)
+ count = self.payment_count - len(kept)
+ if count <= 0:
+ return []
+ currency = self.currency_id
+ events = self._principal_events()
+ previous, balance = self._schedule_start(kept, events)
+ events = [event for event in events if event[0] > previous]
+ payments_per_year = self._payments_per_year()
+ instalment = principal_per_period = rate_used = None
+ values = []
+ for step in range(count):
+ index = len(kept) + step
+ due = self._due_date(index)
+ period_events = [event for event in events if previous < event[0] <= due]
+ rate = self._rate_at(previous + ONE_DAY)
+ interest = 0.0
+ if due > previous:
+ interest, balance = self._interest_for(
+ previous, due, balance, period_events, rate, self._period_days(index))
+ balance = currency.round(balance)
+ interest = currency.round(interest)
+ left = count - step
+ if period_events or instalment is None:
+ principal_per_period = balance / left
+ if period_events or instalment is None or rate != rate_used:
+ instalment = annuity(balance, rate / 100 / payments_per_year, left)
+ rate_used = rate
+ if self.loan_type == 'lease':
+ # the payments of the contract, the liability being their present value
+ instalment = self.lease_payment
+ if left == 1:
+ principal = balance
+ elif self.schedule_method == 'annuity':
+ principal = min(max(currency.round(instalment - interest), 0.0), balance)
+ else:
+ principal = min(currency.round(principal_per_period), balance)
+ values.append({
+ 'date': due,
+ 'opening_balance': balance,
+ 'principal': principal,
+ 'interest': interest,
+ 'rate': rate,
+ })
+ balance = currency.round(balance - principal)
+ previous = max(previous, due)
+ return values
+
+ def _imported_schedule_values(self, kept):
+ """ The unpaid lines of an imported schedule, as imported or changed by hand. The principal repaid
+ early comes off the last lines (the loan ends earlier); the last line repays what is left. """
+ self.ensure_one()
+ currency = self.currency_id
+ lines = (self.line_ids - kept).sorted('sequence')
+ events = self._principal_events()
+ previous, balance = self._schedule_start(kept, events)
+ balance = currency.round(balance + sum(amount for day, amount, _settles in events if day > previous))
+ values = [{
+ 'date': line.date,
+ 'opening_balance': line.opening_balance,
+ 'principal': line.principal,
+ 'interest': line.interest,
+ 'rate': line.rate,
+ } for line in lines]
+ excess = currency.round(sum(vals['principal'] for vals in values) - balance)
+ while values and currency.compare_amounts(excess, 0.0) > 0:
+ cut = min(values[-1]['principal'], excess)
+ values[-1]['principal'] = currency.round(values[-1]['principal'] - cut)
+ excess = currency.round(excess - cut)
+ if currency.is_zero(values[-1]['principal']):
+ values.pop()
+ return values
+
+ def _generate_schedule(self, date_from=None):
+ """ Compute the unpaid lines again, from the principal drawn, keeping the paid lines (and the unpaid
+ lines due before `date_from`, when given). """
+ for loan in self:
+ kept = loan._kept_lines(date_from)
+ values = loan._compute_schedule_values(kept)
+ lines = (loan.line_ids - kept).sorted('sequence')
+ commands = []
+ for number, vals in enumerate(values, start=len(kept) + 1):
+ vals = {**vals, 'sequence': number}
+ if lines:
+ commands.append(Command.update(lines[0].id, vals))
+ lines = lines[1:]
+ else:
+ commands.append(Command.create(vals))
+ commands += [Command.unlink(line.id) for line in lines]
+ loan.with_context(**internal_context(SCHEDULE_UPDATE)).line_ids = commands
+ if loan.schedule_method == 'imported' and loan.payment_count != len(kept) + len(values):
+ loan.with_context(**internal_context(NO_REGENERATE)).payment_count = max(len(kept) + len(values), 1)
+ loan._chain_balances()
+
+ def _regenerate_from(self, rate_date):
+ """ Compute again the unpaid lines of the running loans that a rate changing on `rate_date` changes:
+ those due after it, or on it with the actual days (their last day accrues at the new rate). """
+ for loan in self.filtered(lambda loan: loan.state == 'running' and loan.line_ids):
+ date_from = rate_date and (rate_date if loan.interest_method == 'actual_365' else rate_date + ONE_DAY)
+ loan._generate_schedule(date_from=date_from)
+
+ def _chain_balances(self):
+ """ Chain the opening and closing balances of the unpaid lines, the last one repaying the balance left
+ (it takes the residue of the rounding and of the changes made by hand). """
+ for loan in self:
+ currency = loan.currency_id
+ lines = loan.line_ids.sorted('sequence')
+ kept = lines.filtered(lambda line: line.state == 'posted')
+ unpaid = lines - kept
+ if not unpaid:
+ continue
+ events = loan._principal_events()
+ previous, balance = loan._schedule_start(loan._kept_lines(), events)
+ updates = []
+ for line in unpaid:
+ balance += sum(amount for day, amount, _settles in events if previous < day <= line.date)
+ balance = currency.round(balance)
+ principal = balance if line == unpaid[-1] else line.principal
+ if currency.compare_amounts(principal, 0.0) < 0:
+ raise UserError(self.env._(
+ 'The principal of the lines exceeds the balance of the loan %s: lower it.',
+ loan.display_name))
+ vals = {}
+ if currency.compare_amounts(line.opening_balance, balance):
+ vals['opening_balance'] = balance
+ if currency.compare_amounts(line.principal, principal):
+ vals['principal'] = principal
+ if vals:
+ updates.append((line, vals))
+ balance = currency.round(balance - principal)
+ previous = max(previous, line.date)
+ for line, vals in updates:
+ line.with_context(**internal_context(SCHEDULE_UPDATE)).write(vals)
+
+ # ------------------------------------------------------------
+ # Balances at a date
+ # ------------------------------------------------------------
+
+ def _lines_unpaid_at(self, day):
+ """ The lines not paid on `day`. """
+ return self.line_ids.filtered(lambda line: line.state != 'posted' or line.payment_date > day).sorted('sequence')
+
+ def _outstanding_at(self, day):
+ """ The principal drawn and not repaid on `day`, in the currency of the loan. """
+ self.ensure_one()
+ events = self._principal_events(projection=False)
+ paid = self.line_ids.filtered(lambda line: line.state == 'posted' and line.payment_date <= day)
+ return self.currency_id.round(
+ sum(amount for event_day, amount, _settles in events if event_day <= day) - sum(paid.mapped('principal')))
+
+ def _principal_due_within(self, day, months=12):
+ """ The principal not paid on `day` and due within `months` months after it, in the loan currency. """
+ self.ensure_one()
+ limit = day + relativedelta(months=months)
+ due = sum(self._lines_unpaid_at(day).filtered(lambda line: line.date <= limit).mapped('principal'))
+ return min(self.currency_id.round(due), max(self._outstanding_at(day), 0.0))
+
+ def _accrued_interest_at(self, day):
+ """ The interest accrued and not due on `day`: since the last due date, early repayment or the first
+ drawdown. """
+ self.ensure_one()
+ events = self._principal_events(projection=False)
+ starts = [line.date for line in self.line_ids if line.date <= day]
+ starts += [event_day for event_day, _amount, settles in events if settles and event_day <= day]
+ if not events or events[0][0] > day:
+ return 0.0
+ start = max(starts) if starts else events[0][0]
+ balance = (sum(amount for event_day, amount, _settles in events if event_day <= start)
+ - sum(self.line_ids.filtered(lambda line: line.date <= start).mapped('principal')))
+ later = self.line_ids.filtered(lambda line: line.date > day).sorted('sequence')[:1]
+ index = later.sequence - 1 if later else len(self.line_ids)
+ interest, _balance = self._interest_for(
+ start, day, balance, [event for event in events if start < event[0] <= day],
+ self._rate_at(start + ONE_DAY), self._period_days(index))
+ return self.currency_id.round(max(interest, 0.0))
+
+ def _account_balance_at(self, account, day, company_currency=False):
+ """ The credit balance of `account` in the entries of the loan up to `day`, in the loan currency (or
+ in the company currency). """
+ self.ensure_one()
+ lines = self.env['account.move.line'].search(Domain([
+ ('move_id.loan_id', '=', self.id),
+ ('account_id', '=', account.id),
+ ('parent_state', '=', 'posted'),
+ ('date', '<=', day),
+ ]))
+ if company_currency:
+ return -self.company_id.currency_id.round(sum(lines.mapped('balance')))
+ return -self.currency_id.round(sum(lines.mapped('amount_currency')))
+
+ # ------------------------------------------------------------
+ # Entries
+ # ------------------------------------------------------------
+
+ def _bank_account(self, journal, direction):
+ """ The account of the bank leg of an entry in `journal`: the outstanding receipts (direction
+ 'inbound') or payments ('outbound') account of the journal, which the bank statement line is matched
+ with, or the bank account itself when the loan posts there. """
+ self.ensure_one()
+ if self.bank_posting == 'bank':
+ account = journal.default_account_id
+ else:
+ method_lines = journal[f'{direction}_payment_method_line_ids']
+ account = method_lines.payment_account_id[:1]
+ if not account:
+ xmlid = ('account_journal_payment_debit_account_id' if direction == 'inbound'
+ else 'account_journal_payment_credit_account_id')
+ account = self.env['account.chart.template'].with_company(journal.company_id).ref(
+ xmlid, raise_if_not_found=False)
+ if not account:
+ raise UserError(self.env._(
+ 'The journal %s has no outstanding account: set one on its payment methods.', journal.display_name))
+ return account
+
+ def _principal_lines(self, amount, day, label):
+ """ The debits repaying `amount` of principal on `day`: the short-term account first, as far as the
+ reclassifications moved principal there, then the long-term account. """
+ self.ensure_one()
+ short_term = min(amount, max(self._account_balance_at(self.account_short_term_id, day), 0.0))
+ return [
+ (self.account_short_term_id, short_term, label),
+ (self.account_long_term_id, self.currency_id.round(amount - short_term), label),
+ ]
+
+ def _settle_exchange(self, move):
+ """ Realize the exchange difference on the principal that `move` repays, in a loan in a foreign
+ currency: the liability accounts it debits keep the rest of their balance at their carrying rate (the
+ rate of the drawdowns), the difference goes to the exchange gain or loss accounts of the company. A
+ liability repaid in full is back to zero in the company currency too.
+
+ :return: the exchange difference entry, or an empty recordset
+ """
+ self.ensure_one()
+ currency = self.currency_id
+ company = self.company_id
+ company_currency = company.currency_id
+ exchange = self.env['account.move']
+ if currency == company_currency:
+ return exchange
+ commands = []
+ total = 0.0
+ label = self.env._('%(loan)s - Exchange difference', loan=self.name)
+ for account in self.account_long_term_id | self.account_short_term_id:
+ entry_lines = move.line_ids.filtered(lambda line, account=account: line.account_id == account)
+ entry_currency = -sum(entry_lines.mapped('amount_currency'))
+ if currency.compare_amounts(entry_currency, 0.0) >= 0:
+ # not repaid by this entry: a drawdown or the reclassification receiving the principal
+ continue
+ credit_currency = self._account_balance_at(account, move.date)
+ credit_company = self._account_balance_at(account, move.date, company_currency=True)
+ before_currency = credit_currency - entry_currency
+ before_company = credit_company + sum(entry_lines.mapped('balance'))
+ if currency.compare_amounts(before_currency, 0.0) <= 0:
+ continue
+ target = company_currency.round(before_company * credit_currency / before_currency)
+ difference = company_currency.round(target - credit_company)
+ if company_currency.is_zero(difference):
+ continue
+ commands.append(Command.create({
+ 'account_id': account.id,
+ 'partner_id': self.partner_id.id,
+ 'name': label,
+ 'currency_id': currency.id,
+ 'amount_currency': 0.0,
+ 'balance': -difference,
+ }))
+ total += difference
+ if not commands:
+ return exchange
+ if company_currency.compare_amounts(total, 0.0) > 0:
+ account = company.expense_currency_exchange_account_id
+ else:
+ account = company.income_currency_exchange_account_id
+ journal = company.currency_exchange_journal_id or self.journal_id
+ if not account or not journal:
+ raise UserError(self.env._(
+ 'Set the exchange gain and loss accounts and journal of %s in the accounting settings: the loan '
+ '%s in a foreign currency realizes an exchange difference.', company.display_name, self.display_name))
+ commands.append(Command.create({
+ 'account_id': account.id,
+ 'name': label,
+ 'currency_id': company_currency.id,
+ 'amount_currency': company_currency.round(total),
+ 'balance': company_currency.round(total),
+ }))
+ exchange = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'journal_id': journal.id,
+ 'date': move.date,
+ 'ref': label,
+ 'loan_id': self.id,
+ 'loan_entry_type': 'exchange',
+ 'line_ids': commands,
+ })
+ exchange.action_post()
+ move.with_context(**internal_context(LOAN_WRITE)).loan_exchange_move_id = exchange
+ return exchange
+
+ def _check_lock_dates(self, day, journal, has_tax=False):
+ """ Refuse an entry of the loan dated in a locked period: Odoo would post it on a later date, and the
+ schedule would no longer match the books. """
+ self.ensure_one()
+ locks = self.company_id._get_violated_lock_dates(day, has_tax, journal)
+ if locks:
+ raise UserError(self.env._(
+ 'The entry of %(loan)s cannot be dated %(date)s: %(locks)s. Choose a later date, or ask your '
+ 'accountant to open the period.',
+ loan=self.display_name, date=format_date(self.env, day),
+ locks=self.env['res.company']._format_lock_dates(locks)))
+
+ def _create_move(self, journal, day, lines, entry_type, ref):
+ """ Post an entry of the loan.
+
+ :param lines: list of (account, amount in the loan currency, debit if positive, label[, extra values of
+ the line]); the last line balances the entry, in the loan currency and in the company currency
+ """
+ self.ensure_one()
+ currency = self.currency_id
+ company_currency = self.company_id.currency_id
+ lines = [line for line in lines[:-1] if not currency.is_zero(line[1])] + lines[-1:]
+ has_tax = any(extra and extra[0].get('tax_repartition_line_id') for _acc, _amt, _lbl, *extra in lines)
+ self._check_lock_dates(day, journal, has_tax=has_tax)
+ total_currency = total_balance = 0.0
+ commands = []
+ for position, (account, amount, label, *extra) in enumerate(lines):
+ if position == len(lines) - 1:
+ amount, balance = -total_currency, -total_balance
+ else:
+ balance = currency._convert(amount, company_currency, self.company_id, day)
+ total_currency += amount
+ total_balance += balance
+ commands.append(Command.create({
+ 'account_id': account.id,
+ 'partner_id': self.partner_id.id,
+ 'name': label,
+ 'currency_id': currency.id,
+ 'amount_currency': amount,
+ 'balance': balance,
+ **(extra[0] if extra else {}),
+ }))
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'journal_id': journal.id,
+ 'date': day,
+ 'ref': ref,
+ 'partner_id': self.partner_id.id,
+ 'currency_id': currency.id,
+ 'loan_id': self.id,
+ 'loan_entry_type': entry_type,
+ 'line_ids': commands,
+ })
+ move.action_post()
+ return move
+
+ # ------------------------------------------------------------
+ # Leases
+ # ------------------------------------------------------------
+
+ def _rou_account(self):
+ """ The account debited by the initial recognition of the lease. """
+ self.ensure_one()
+ return self.account_rou_id
+
+ def _recognition(self):
+ """ The posted initial recognition of the lease. """
+ self.ensure_one()
+ return self.operation_ids.filtered(lambda op: op.operation_type == 'recognition' and op.state == 'posted')
+
+ def _recognize_lease(self):
+ """ Post the initial recognition of the lease on its start date: Dr right-of-use asset / Cr long-term
+ lease liability, at the present value of the lease payments.
+
+ :return: the account.loan.operation of the recognition
+ """
+ self.ensure_one()
+ currency = self.currency_id
+ if currency.compare_amounts(self.principal, 0.0) <= 0:
+ raise UserError(self.env._('The lease %s has no liability to recognize.', self.display_name))
+ label = self.env._('%(lease)s - Initial recognition', lease=self.name)
+ move = self._create_move(self.journal_id, self.date_start, [
+ (self._rou_account(), self.principal, label),
+ (self.account_long_term_id, 0.0, label),
+ ], 'recognition', label)
+ operation = self.env['account.loan.operation'].create({
+ 'loan_id': self.id,
+ 'operation_type': 'recognition',
+ 'date': self.date_start,
+ 'amount': self.principal,
+ 'journal_id': self.journal_id.id,
+ 'move_id': move.id,
+ })
+ self.message_post(body=self.env._(
+ 'Lease liability of %(amount)s recognized on %(date)s.',
+ amount=currency.format(self.principal), date=format_date(self.env, self.date_start)))
+ return operation
+
+ # ------------------------------------------------------------
+ # Withholding tax
+ # ------------------------------------------------------------
+
+ def _withholding_details(self, base):
+ """ The tax withheld on the interest `base`, computed by the withholding tax.
+
+ :return: list of (account.tax.repartition.line, amount withheld, positive), in the loan currency
+ """
+ self.ensure_one()
+ tax = self.withholding_tax_id
+ currency = self.currency_id
+ if not tax or currency.compare_amounts(base, 0.0) <= 0:
+ return []
+ # the tax computation itself: the withholding taxes deferred to the payments by the localizations are
+ # filtered out of the documents only, not of this one
+ computation = tax._get_tax_details(price_unit=base, quantity=1.0, precision_rounding=currency.rounding)
+ details = []
+ for tax_data in computation['taxes_data']:
+ if tax_data.get('is_reverse_charge'):
+ continue
+ repartition = tax_data['tax'].invoice_repartition_line_ids.filtered(
+ lambda rep: rep.repartition_type == 'tax' and rep.factor >= 0)
+ for rep in repartition:
+ details.append((rep, currency.round(-tax_data['tax_amount'] * rep.factor)))
+ return details
+
+ def _withholding_on_bill_payment(self):
+ """ Whether the tax of a lease is withheld when its bills are paid: a withholding tax of the payments
+ (Withhold, of the Withholding Tax module of Odoo) is left out of the bills and withheld by Odoo on their
+ payment. """
+ self.ensure_one()
+ tax = self.withholding_tax_id
+ return self.loan_type == 'lease' and bool(tax._fields.get('is_withholding_tax') and tax.is_withholding_tax)
+
+ def _withholding_amount(self, base):
+ """ The tax withheld on the interest `base`, in the loan currency; nothing on the bills of a lease
+ whose tax is withheld on the payments. """
+ self.ensure_one()
+ if self._withholding_on_bill_payment():
+ return 0.0
+ return self.currency_id.round(sum(amount for _rep, amount in self._withholding_details(base)))
+
+ def _interest_line_values(self):
+ """ The extra values of the interest line of a payment: the base tags of the withholding tax. """
+ self.ensure_one()
+ tags = self.withholding_tax_id.invoice_repartition_line_ids.filtered(
+ lambda rep: rep.repartition_type == 'base').tag_ids
+ return {'tax_tag_ids': [Command.set(tags.ids)]} if tags else {}
+
+ def _withholding_lines(self, base, withheld, day, label):
+ """ The lines crediting the tax withheld on the interest `base` to the accounts of the tax, as tax lines
+ of the tax: `withheld` may differ from the computation, the difference goes on the last line.
+
+ :return: list of (account, amount, label, extra values) for `_create_move`
+ """
+ self.ensure_one()
+ currency = self.currency_id
+ details = self._withholding_details(base)
+ if not details or currency.is_zero(withheld):
+ return []
+ difference = withheld - sum(amount for _rep, amount in details)
+ details[-1] = (details[-1][0], currency.round(details[-1][1] + difference))
+ base_balance = currency._convert(base, self.company_id.currency_id, self.company_id, day)
+ lines = []
+ for rep, amount in details:
+ if not rep.account_id:
+ raise UserError(self.env._(
+ 'Set an account on the distribution of the withholding tax %s.', rep.tax_id.display_name))
+ lines.append((rep.account_id, -amount, label, {
+ 'tax_repartition_line_id': rep.id,
+ 'tax_tag_ids': [Command.set(rep.tag_ids.ids)],
+ 'tax_base_amount': base_balance,
+ }))
+ return lines
+
+ def _check_paid_up_to(self, day):
+ """ Refuse an operation dated on or before the due date of a paid line. """
+ self.ensure_one()
+ paid = self.line_ids.filtered(lambda line: line.state == 'posted')
+ if paid and day <= max(paid.mapped('date')):
+ raise UserError(self.env._(
+ 'The loan %(loan)s is paid up to %(date)s: date the operation after it.',
+ loan=self.display_name, date=format_date(self.env, max(paid.mapped('date')))))
+
+ def _update_state(self):
+ """ Close the loans fully repaid, open again the others. """
+ for loan in self.filtered(lambda loan: loan.state in ('running', 'closed')):
+ done = (loan.drawn_amount and loan.currency_id.is_zero(loan.outstanding_amount)
+ and not loan.line_ids.filtered(lambda line: line.state == 'draft'))
+ state = 'closed' if done else 'running'
+ if loan.state != state:
+ loan.state = state
+
+ # ------------------------------------------------------------
+ # Reclassification
+ # ------------------------------------------------------------
+
+ @api.model
+ def _cron_reclassify(self):
+ """ Reclassify the running loans and leases of the companies that ask for it at the end of the month
+ before, once: a loan already reclassified on that date or later is left as it is. A loan that cannot be
+ reclassified says why in its chatter, once per month end, and is tried again the next day. """
+ companies = self.env['res.company'].search([('loan_auto_reclassify', '=', True)])
+ day = fields.Date.context_today(self) + relativedelta(day=1) - ONE_DAY
+ loans = self.search([('company_id', 'in', companies.ids), ('state', '=', 'running'),
+ ('date_start', '<=', day)])
+ for loan in loans:
+ done = loan.move_ids.filtered(lambda move: (
+ move.loan_entry_type == 'reclassification' and move.state == 'posted' and move.date >= day
+ and not move.reversed_entry_id and not move.reversal_move_ids))
+ if done:
+ continue
+ try:
+ with self.env.cr.savepoint():
+ loan.with_company(loan.company_id)._reclassify(day)
+ except UserError as error:
+ if loan.reclassify_failed_on != day:
+ loan.reclassify_failed_on = day
+ loan.message_post(body=self.env._(
+ 'The loan could not be reclassified at %(date)s: %(error)s',
+ date=format_date(self.env, day), error=error.args[0]))
+
+ def _reclassification_amount(self, day):
+ """ What moves from the long-term to the short-term account on `day` (negative: back) so that the
+ short-term account holds the principal due within 12 months. """
+ self.ensure_one()
+ target = self._principal_due_within(day)
+ return self.currency_id.round(target - self._account_balance_at(self.account_short_term_id, day))
+
+ def _reclassify(self, day, journal=None):
+ """ Post the reclassification of the loan on `day`; nothing when the short-term balance is right.
+
+ :return: the entry, or an empty recordset
+ """
+ self.ensure_one()
+ later = self.move_ids.filtered(
+ lambda move: move.loan_entry_type == 'reclassification' and move.state == 'posted' and move.date > day)
+ if later:
+ raise UserError(self.env._(
+ 'The loan %(loan)s is already reclassified on %(date)s: reclassify in the order of the dates.',
+ loan=self.display_name, date=format_date(self.env, max(later.mapped('date')))))
+ draft_bills = self.move_ids.filtered(
+ lambda move: move.loan_entry_type == 'bill' and move.state == 'draft' and move.date <= day)
+ if draft_bills:
+ raise UserError(self.env._(
+ 'Post the bills of the lease %(lease)s dated up to %(date)s first.', lease=self.display_name,
+ date=format_date(self.env, day)))
+ amount = self._reclassification_amount(day)
+ if self.currency_id.is_zero(amount):
+ return self.env['account.move']
+ label = self.env._('%(loan)s - Principal due within 12 months', loan=self.name)
+ move = self._create_move(
+ journal or self.journal_id, day,
+ [(self.account_long_term_id, amount, label), (self.account_short_term_id, -amount, label)],
+ 'reclassification', self.env._('%(loan)s - Reclassification', loan=self.name))
+ if self.currency_id.compare_amounts(amount, 0.0) > 0:
+ self.message_post(body=self.env._(
+ 'Reclassified at %(date)s: %(amount)s moved to the short-term account.',
+ date=format_date(self.env, day), amount=self.currency_id.format(amount)))
+ else:
+ self.message_post(body=self.env._(
+ 'Reclassified at %(date)s: %(amount)s moved back to the long-term account.',
+ date=format_date(self.env, day), amount=self.currency_id.format(-amount)))
+ return move
+
+ # ------------------------------------------------------------
+ # Demo
+ # ------------------------------------------------------------
+
+ @api.model
+ def _load_demo_loan(self):
+ """ A bank loan for the demo: drawn in two tranches, the instalments due paid, reclassified at the end
+ of the last month; an equipment loan and an office lease too. """
+ company = self.env.company
+ if not company.chart_template or self.search_count([('company_id', '=', company.id)], limit=1):
+ return
+ try:
+ with self.env.cr.savepoint():
+ self._demo_loan(company)
+ except Exception: # the demo is not worth failing the installation
+ _logger.exception('Error while creating the demo loan of %s', company.name)
+
+ @api.model
+ def _demo_loan(self, company):
+ self._demo_configure(company)
+ today = fields.Date.context_today(self)
+ month_end = today + relativedelta(day=1) - ONE_DAY
+ Partner = self.env['res.partner']
+ start = today + relativedelta(months=-7, day=1)
+ loan = self.create({
+ 'partner_id': Partner.create({'name': 'Harbor Commercial Bank', 'is_company': True}).id,
+ 'lender_ref': f'TL-{start.year}-0417',
+ 'principal': 120000.0,
+ 'date_start': start,
+ 'date_first_payment': start + relativedelta(months=1, day=31),
+ 'payment_count': 36,
+ 'rate_type': 'floating',
+ 'rate': 6.0,
+ 'rate_line_ids': [Command.create({'date': start + relativedelta(months=4), 'rate': 6.5})],
+ })
+ loan.action_confirm()
+ self._demo_draw(loan, start, 80000.0)
+ self._demo_draw(loan, start + relativedelta(months=2, day=10), 40000.0)
+ start = today + relativedelta(months=-14, day=1)
+ equipment = self.create({
+ 'partner_id': Partner.create({'name': 'Northgate Equipment Finance', 'is_company': True}).id,
+ 'lender_ref': 'EQ-88231',
+ 'principal': 60000.0,
+ 'date_start': start,
+ 'payment_frequency': 'quarterly',
+ 'payment_count': 20,
+ 'rate': 5.25,
+ 'schedule_method': 'fixed_principal',
+ 'interest_method': 'actual_365',
+ })
+ equipment.action_confirm()
+ self._demo_draw(equipment, start, 60000.0)
+ lease = self.create({
+ 'loan_type': 'lease',
+ 'partner_id': Partner.create({'name': 'Riverside Office Properties', 'is_company': True}).id,
+ 'lender_ref': f'OFFICE-3F-{today.year}',
+ 'lease_payment': 2500.0,
+ 'date_start': today + relativedelta(months=-5, day=1),
+ 'payment_count': 36,
+ 'rate': 5.0,
+ })
+ lease.action_confirm()
+ loans = loan | equipment
+ self._demo_pay(loans, month_end)
+ self._demo_bill(lease, month_end)
+ for record in loans | lease:
+ record._reclassify(month_end)
+ self._demo_pay(loans, today - ONE_DAY)
+ self._demo_bill(lease, today - ONE_DAY)
+
+ @api.model
+ def _demo_draw(self, loan, day, amount):
+ self.env['account.loan.drawdown'].create({'loan_id': loan.id, 'date': day, 'amount': amount}).action_confirm()
+
+ @api.model
+ def _demo_pay(self, loans, until):
+ lines = loans.line_ids.filtered(lambda line: line.state == 'draft' and line.date <= until)
+ for line in lines.sorted(lambda line: (line.date, line.sequence)):
+ self.env['account.loan.pay'].create({'line_id': line.id}).action_confirm()
+
+ @api.model
+ def _demo_bill(self, leases, until):
+ lines = leases.line_ids.filtered(lambda line: line.state == 'draft' and line.date <= until)
+ for line in lines.sorted(lambda line: (line.date, line.sequence)):
+ line._create_bill()
+
+ @api.model
+ def _demo_configure(self, company):
+ """ The loan accounts and journals of the demo company, created when the chart has none. """
+ Account = self.env['account.account'].with_company(company)
+ domain = Account._check_company_domain(company)
+
+ def account(xmlid, name, account_type):
+ found = self.env.ref(f'om_account_loan.{xmlid}_{company.id}', raise_if_not_found=False)
+ if found:
+ return found
+ code = Account._search_new_account_code(
+ Account.search([*domain, ('account_type', '=', account_type)], limit=1).code or '2900')
+ record = Account.create({'name': name, 'code': code, 'account_type': account_type})
+ self.env['ir.model.data'].create({
+ 'module': 'om_account_loan', 'name': f'{xmlid}_{company.id}', 'model': 'account.account',
+ 'res_id': record.id, 'noupdate': True})
+ return record
+
+ Journal = self.env['account.journal'].with_company(company)
+ company.write({
+ 'loan_long_term_account_id': (company.loan_long_term_account_id or account(
+ 'demo_loan_long_term', 'Bank Loans (non-current)', 'liability_non_current')).id,
+ 'loan_short_term_account_id': (company.loan_short_term_account_id or account(
+ 'demo_loan_short_term', 'Bank Loans (current)', 'liability_current')).id,
+ 'loan_interest_account_id': (company.loan_interest_account_id or account(
+ 'demo_loan_interest', 'Interest on Loans', 'expense')).id,
+ 'loan_journal_id': (company.loan_journal_id or Journal.search(
+ [*Journal._check_company_domain(company), ('type', '=', 'general')], limit=1)).id,
+ 'loan_bank_journal_id': (company.loan_bank_journal_id or Journal.search(
+ [*Journal._check_company_domain(company), ('type', '=', 'bank')], limit=1)).id,
+ 'lease_long_term_account_id': (company.lease_long_term_account_id or account(
+ 'demo_lease_long_term', 'Lease Liabilities (non-current)', 'liability_non_current')).id,
+ 'lease_short_term_account_id': (company.lease_short_term_account_id or account(
+ 'demo_lease_short_term', 'Lease Liabilities (current)', 'liability_current')).id,
+ 'lease_rou_account_id': (company.lease_rou_account_id or account(
+ 'demo_lease_rou', 'Right-of-use Assets', 'asset_non_current')).id,
+ 'lease_bill_journal_id': (company.lease_bill_journal_id or Journal.search(
+ [*Journal._check_company_domain(company), ('type', '=', 'purchase')], limit=1)).id,
+ })
diff --git a/om_account_loan/models/account_loan_line.py b/om_account_loan/models/account_loan_line.py
new file mode 100644
index 000000000..e1d0e4acd
--- /dev/null
+++ b/om_account_loan/models/account_loan_line.py
@@ -0,0 +1,406 @@
+import logging
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.fields import Command
+from odoo.tools import groupby
+from odoo.tools.misc import format_date
+
+from .loan_context import LOAN_REVERSE, SCHEDULE_UPDATE, internal, internal_context
+
+
+# fields set by the schedule, which a paid line keeps
+SCHEDULE_FIELDS = {'date', 'opening_balance', 'principal', 'interest', 'rate', 'sequence', 'withholding'}
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountLoanLine(models.Model):
+ _name = 'account.loan.line'
+ _description = 'Loan Schedule Line'
+ _order = 'loan_id, sequence, id'
+ _check_company_auto = True
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True, ondelete='cascade', index=True)
+ company_id = fields.Many2one(related='loan_id.company_id', store=True, index=True)
+ currency_id = fields.Many2one(related='loan_id.currency_id', store=True)
+ partner_id = fields.Many2one(related='loan_id.partner_id', string='Lender')
+ loan_type = fields.Selection(related='loan_id.loan_type', string='Type', store=True)
+ sequence = fields.Integer(string='#', required=True, default=1)
+ date = fields.Date(string='Due Date', required=True)
+ opening_balance = fields.Monetary(
+ string='Opening Balance', help="The principal owed on the due date, before the instalment.")
+ principal = fields.Monetary(string='Principal')
+ interest = fields.Monetary(string='Interest')
+ amount = fields.Monetary(string='Instalment', compute='_compute_amounts', store=True)
+ withholding = fields.Monetary(
+ string='Withholding Tax', compute='_compute_withholding', store=True, readonly=False,
+ help="The tax withheld on the interest, deducted from what is paid to the lender.")
+ net_amount = fields.Monetary(
+ string='Net Payment', compute='_compute_amounts', store=True,
+ help="The instalment less the tax withheld: what the lender receives, taxes on a lease bill excluded.")
+ closing_balance = fields.Monetary(string='Closing Balance', compute='_compute_amounts', store=True)
+ rate = fields.Float(string='Rate (%)', digits=(16, 4), help="The annual rate of the period.")
+ state = fields.Selection(
+ [('draft', 'To Pay'), ('posted', 'Paid')], string='Status', required=True, default='draft', copy=False)
+ move_id = fields.Many2one('account.move', string='Journal Entry', readonly=True, copy=False, check_company=True)
+ payment_date = fields.Date(string='Paid On', readonly=True, copy=False)
+ loan_state = fields.Selection(related='loan_id.state', string='Loan Status')
+ is_last_paid = fields.Boolean(compute='_compute_is_last_paid')
+ is_next = fields.Boolean(
+ string='To Post Next', compute='_compute_is_next',
+ help="The next instalment to pay (or lease payment to bill) of a running loan: the schedule is paid in order.")
+ bill_status = fields.Selection(
+ [('to_bill', 'To Bill'), ('draft', 'Draft Bill'), ('posted', 'Billed'), ('paid', 'Paid')],
+ string='Bill', compute='_compute_bill_status', help="Where the bill of a lease payment stands.")
+
+ @api.depends('principal', 'interest', 'opening_balance', 'withholding')
+ def _compute_amounts(self):
+ for line in self:
+ line.amount = line.principal + line.interest
+ line.net_amount = line.amount - line.withholding
+ line.closing_balance = line.opening_balance - line.principal
+
+ @api.depends('interest', 'loan_id.withholding_tax_id')
+ def _compute_withholding(self):
+ for line in self:
+ if line.state == 'posted' or not line.loan_id:
+ # a paid line keeps what was withheld
+ line.withholding = line.withholding
+ else:
+ line.withholding = line.loan_id._withholding_amount(line.interest)
+
+ @api.depends('loan_type', 'state', 'move_id.state', 'move_id.payment_state')
+ def _compute_bill_status(self):
+ for line in self:
+ move = line.move_id
+ if line.loan_type != 'lease':
+ line.bill_status = False
+ elif line.state != 'posted' or not move:
+ line.bill_status = 'to_bill'
+ elif move.state == 'draft':
+ line.bill_status = 'draft'
+ elif move.payment_state in ('paid', 'in_payment'):
+ line.bill_status = 'paid'
+ else:
+ line.bill_status = 'posted'
+
+ @api.depends('state', 'loan_id.line_ids.state')
+ def _compute_is_last_paid(self):
+ for line in self:
+ paid = line.loan_id.line_ids.filtered(lambda other: other.state == 'posted')
+ line.is_last_paid = line.state == 'posted' and line == paid.sorted('sequence')[-1:]
+
+ @api.depends('state', 'loan_id.state', 'loan_id.line_ids.state', 'loan_id.outstanding_amount')
+ def _compute_is_next(self):
+ for line in self:
+ loan = line.loan_id
+ line.is_next = (
+ line.state == 'draft' and loan.state == 'running' and line == loan.next_line_id
+ # a loan pays nothing before it is drawn down
+ and (loan.loan_type == 'lease' or loan.currency_id.compare_amounts(loan.outstanding_amount, 0.0) > 0)
+ )
+
+ def write(self, vals):
+ changes = []
+ if not internal(self.env, SCHEDULE_UPDATE):
+ if SCHEDULE_FIELDS & set(vals) and self.filtered(lambda line: line.state == 'posted'):
+ raise UserError(self.env._('A paid instalment cannot be changed.'))
+ if {'state', 'move_id', 'payment_date', 'loan_id'} & set(vals):
+ raise UserError(self.env._('Pay an instalment with its Pay button.'))
+ changes = [(line, line._describe_change(vals)) for line in self]
+ res = super().write(vals)
+ for line, description in changes:
+ if description:
+ line.loan_id.message_post(body=self.env._(
+ 'Instalment %(number)s changed: %(changes)s.', number=line.sequence, changes=description))
+ if not internal(self.env, SCHEDULE_UPDATE) and {'principal', 'date'} & set(vals):
+ self.loan_id._chain_balances()
+ return res
+
+ def _describe_change(self, vals):
+ """ The changes `vals` makes to the due date or the amounts of the line, as a text for the chatter. """
+ self.ensure_one()
+ currency = self.currency_id
+ parts = []
+ if 'date' in vals and fields.Date.to_date(vals['date']) != self.date:
+ parts.append(self.env._('due date %(old)s to %(new)s', old=format_date(self.env, self.date),
+ new=format_date(self.env, fields.Date.to_date(vals['date']))))
+ for fname in ('principal', 'interest'):
+ if fname in vals and currency.compare_amounts(vals[fname] or 0.0, self[fname]):
+ parts.append(self.env._(
+ '%(field)s %(old)s to %(new)s', field=self._fields[fname].get_description(self.env)['string'],
+ old=currency.format(self[fname]), new=currency.format(vals[fname] or 0.0)))
+ return ', '.join(parts)
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_paid(self):
+ if not internal(self.env, SCHEDULE_UPDATE) and self.filtered(lambda line: line.state == 'posted'):
+ raise UserError(self.env._('A paid instalment cannot be deleted.'))
+
+ def action_pay(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Pay Instalment'),
+ 'res_model': 'account.loan.pay',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_line_id': self.id},
+ }
+
+ def action_open_move(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'view_mode': 'form',
+ 'res_id': self.move_id.id,
+ }
+
+ def action_create_bill(self):
+ """ Create the bill of the lease payment and open it. """
+ self.ensure_one()
+ bill = self._create_bill()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'view_mode': 'form',
+ 'res_id': bill.id,
+ }
+
+ def action_create_bills(self):
+ """ Create the bills of the lease payments selected, in the order of their schedule. """
+ lines = self.filtered(lambda line: line.loan_type == 'lease' and line.state == 'draft')
+ if not lines:
+ raise UserError(self.env._('Select lease payments to bill.'))
+ bills = self.env['account.move']
+ for line in lines.sorted(lambda line: (line.loan_id.id, line.sequence)):
+ bills |= line._create_bill()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Lease Bills'),
+ 'res_model': 'account.move',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', bills.ids)],
+ 'context': {'create': False},
+ }
+
+ @api.model
+ def _cron_create_lease_bills(self):
+ """ Bill the lease payments due, of the running leases billed on their due dates. """
+ today = fields.Date.context_today(self)
+ lines = self.search([
+ ('loan_type', '=', 'lease'),
+ ('state', '=', 'draft'),
+ ('date', '<=', today),
+ ('loan_id.state', '=', 'running'),
+ ('loan_id.bill_auto_create', '=', True),
+ ], order='loan_id, sequence')
+ for loan, loan_lines in groupby(lines, key=lambda line: line.loan_id):
+ try:
+ with self.env.cr.savepoint():
+ for line in loan_lines:
+ line.with_company(loan.company_id)._create_bill()
+ except UserError as error:
+ _logger.warning('The lease %s could not be billed: %s', loan.display_name, error)
+ loan.message_post(body=self.env._('The lease payments due could not be billed: %s', error))
+
+ def _check_to_post(self):
+ """ Refuse to pay or bill the line out of the order of the schedule. """
+ self.ensure_one()
+ loan = self.loan_id
+ if self.state == 'posted':
+ raise UserError(self.env._('The instalment %s is already paid.', self.sequence))
+ if loan.state != 'running':
+ raise UserError(self.env._('The loan %s is not running.', loan.display_name))
+ earlier = loan.line_ids.filtered(lambda line: line.state == 'draft' and line.sequence < self.sequence)
+ if earlier:
+ raise UserError(self.env._('Pay the instalments in their order: %s first.', earlier[0].sequence))
+ if loan.loan_type != 'lease' and loan.currency_id.compare_amounts(loan.outstanding_amount, 0.0) <= 0:
+ raise UserError(self.env._('Nothing is drawn on the loan %s: draw it down first.', loan.display_name))
+ if self.currency_id.compare_amounts(self.principal, loan.outstanding_amount) > 0:
+ raise UserError(self.env._(
+ 'The principal of the instalment exceeds the outstanding principal of the loan %s.',
+ loan.display_name))
+
+ def _check_payment_date(self, day):
+ """ Refuse a payment dated before the start of the loan, or before the payment of the instalment
+ before it: the balances of the loan at a date follow the payment dates. """
+ self.ensure_one()
+ loan = self.loan_id
+ if day < loan.date_start:
+ raise UserError(self.env._(
+ 'The payment cannot be dated before the start of the loan %(loan)s, %(date)s.',
+ loan=loan.display_name, date=format_date(self.env, loan.date_start)))
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted' and line.payment_date)
+ last = max(paid.mapped('payment_date'), default=None)
+ if last and day < last:
+ raise UserError(self.env._(
+ 'The instalment before it was paid on %(date)s: date this payment on or after it.',
+ date=format_date(self.env, last)))
+
+ def _post_payment(self, day, journal, withholding=None):
+ """ Post the payment of the instalment on `day` from `journal`: Dr loan (principal) + Dr interest / Cr
+ withholding tax (the tax withheld on the interest) + Cr bank (outstanding payments). An instalment of
+ zero is marked paid without entry.
+
+ :param withholding: the tax withheld, the one of the line by default
+ """
+ self.ensure_one()
+ loan = self.loan_id
+ if loan.loan_type == 'lease':
+ raise UserError(self.env._('A lease payment is paid with the bill of the lessor: create its bill.'))
+ self._check_to_post()
+ self._check_payment_date(day)
+ currency = self.currency_id
+ withholding = self.withholding if withholding is None else withholding
+ if not loan.withholding_tax_id:
+ # nothing is withheld without a withholding tax: no tax line would book it
+ withholding = 0.0
+ move = self.env['account.move']
+ if not currency.is_zero(self.amount):
+ label = self.env._('%(loan)s - Instalment %(number)s', loan=loan.name, number=self.sequence)
+ lines = loan._principal_lines(self.principal, day, label)
+ lines.append((loan.account_interest_id, self.interest,
+ self.env._('%(loan)s - Interest %(number)s', loan=loan.name, number=self.sequence),
+ loan._interest_line_values() if withholding else {}))
+ lines += loan._withholding_lines(
+ self.interest, withholding, day,
+ self.env._('%(loan)s - Withholding tax %(number)s', loan=loan.name, number=self.sequence))
+ lines.append((loan._bank_account(journal, 'outbound'), 0.0, label))
+ move = loan._create_move(journal, day, lines, 'payment', label)
+ self.with_context(**internal_context(SCHEDULE_UPDATE)).write({
+ 'state': 'posted', 'move_id': move.id, 'payment_date': day, 'withholding': withholding})
+ if currency.is_zero(withholding):
+ loan.message_post(body=self.env._(
+ 'Instalment %(number)s paid on %(date)s: %(amount)s (principal %(principal)s, interest '
+ '%(interest)s).', number=self.sequence, date=format_date(self.env, day),
+ amount=currency.format(self.amount), principal=currency.format(self.principal),
+ interest=currency.format(self.interest)))
+ else:
+ loan.message_post(body=self.env._(
+ 'Instalment %(number)s paid on %(date)s: %(amount)s (principal %(principal)s, interest '
+ '%(interest)s), %(withheld)s withheld.', number=self.sequence, date=format_date(self.env, day),
+ amount=currency.format(self.amount), principal=currency.format(self.principal),
+ interest=currency.format(self.interest), withheld=currency.format(withholding)))
+ loan._update_state()
+ return move
+
+ def _prepare_bill_values(self, day):
+ """ The values of the bill of the lease payment: a line per liability account repaid (taxes of the
+ bills) and the interest line (taxes of the bills and withholding tax). """
+ self.ensure_one()
+ loan = self.loan_id
+ currency = self.currency_id
+ label = self.env._('%(lease)s - Lease payment %(number)s', lease=loan.name, number=self.sequence)
+ invoice_lines = [
+ Command.create({
+ 'name': self.env._('%(label)s: principal', label=label),
+ 'account_id': account.id,
+ 'quantity': 1.0,
+ 'price_unit': amount,
+ 'tax_ids': [Command.set(loan.bill_tax_ids.ids)],
+ })
+ for account, amount, _label in loan._principal_lines(self.principal, day, label)
+ if not currency.is_zero(amount)
+ ]
+ if not currency.is_zero(self.interest):
+ invoice_lines.append(Command.create({
+ 'name': self.env._('%(label)s: interest', label=label),
+ 'account_id': loan.account_interest_id.id,
+ 'quantity': 1.0,
+ 'price_unit': self.interest,
+ 'tax_ids': [Command.set((loan.bill_tax_ids | loan.withholding_tax_id).ids)],
+ }))
+ return {
+ 'move_type': 'in_invoice',
+ 'partner_id': loan.partner_id.id,
+ 'journal_id': loan.bill_journal_id.id,
+ 'invoice_date': day,
+ 'date': day,
+ 'currency_id': currency.id,
+ 'ref': label,
+ 'loan_id': loan.id,
+ 'loan_entry_type': 'bill',
+ 'invoice_line_ids': invoice_lines,
+ }
+
+ def _check_bill(self, bill):
+ """ Refuse to post the bill of the lease payment when it no longer matches it: the lessor, the currency
+ or the amount before taxes changed on the bill. The taxes may change. """
+ self.ensure_one()
+ loan = self.loan_id
+ currency = self.currency_id
+ untaxed = sum(bill.invoice_line_ids.mapped('price_subtotal'))
+ if bill.commercial_partner_id != loan.partner_id.commercial_partner_id or bill.currency_id != currency \
+ or currency.compare_amounts(untaxed, self.amount):
+ raise UserError(self.env._(
+ 'The bill %(bill)s no longer matches the lease payment %(number)s of %(lease)s: %(amount)s before '
+ 'taxes, billed by %(lessor)s. Correct the bill, or cancel it on the lease and bill the payment again.',
+ bill=bill.display_name, number=self.sequence, lease=loan.display_name,
+ amount=currency.format(self.amount), lessor=loan.partner_id.display_name))
+
+ def _create_bill(self, day=None):
+ """ Create the vendor bill of the lessor for the lease payment, dated `day` (its due date by default),
+ posted or left in draft as the lease says. The line is then paid with its bill.
+
+ :return: the bill, or an empty recordset for a payment of zero
+ """
+ self.ensure_one()
+ loan = self.loan_id
+ if loan.loan_type != 'lease':
+ raise UserError(self.env._('The instalments of a loan are paid from the bank: pay them.'))
+ self._check_to_post()
+ day = day or self.date
+ self._check_payment_date(day)
+ loan._check_lock_dates(day, loan.bill_journal_id, has_tax=bool(loan.bill_tax_ids or loan.withholding_tax_id))
+ bill = self.env['account.move']
+ if not self.currency_id.is_zero(self.amount):
+ bill = self.env['account.move'].create(self._prepare_bill_values(day))
+ self.with_context(**internal_context(SCHEDULE_UPDATE)).write(
+ {'state': 'posted', 'move_id': bill.id, 'payment_date': day})
+ if bill and loan.bill_posting == 'posted':
+ if bill._loan_needs_document_number():
+ loan.message_post(body=self.env._(
+ 'The bill %(bill)s is left in draft: its journal asks for the document number of the lessor. '
+ 'Enter it and post the bill.', bill=bill.display_name))
+ else:
+ bill.action_post()
+ if bill:
+ loan.message_post(body=self.env._(
+ 'Bill %(bill)s of the lease payment %(number)s created: %(amount)s before taxes.',
+ bill=bill.display_name, number=self.sequence, amount=self.currency_id.format(self.amount)))
+ loan._update_state()
+ return bill
+
+ def action_cancel_payment(self):
+ """ Reverse the payment of the last paid instalment: the line is to pay again. """
+ for line in self:
+ if not line.is_last_paid:
+ raise UserError(self.env._('Only the last paid instalment can be cancelled.'))
+ loan = line.loan_id
+ later = loan.operation_ids.filtered(lambda op: op.state == 'posted' and op.date > line.date)
+ if later:
+ raise UserError(self.env._('Cancel the operations dated after the instalment first.'))
+ move = line.move_id.with_context(**internal_context(LOAN_REVERSE))
+ if move.state == 'draft':
+ move.button_cancel()
+ elif move.state == 'posted':
+ if move.is_invoice() and move.payment_state not in ('not_paid', 'reversed'):
+ raise UserError(self.env._(
+ 'The bill %s is paid: remove its payment first.', move.display_name))
+ loan._check_lock_dates(move.date, move.journal_id, has_tax=bool(move.line_ids.tax_line_id))
+ values = {'date': move.date}
+ if move.is_invoice():
+ values['invoice_date'] = move.invoice_date or move.date
+ move._reverse_moves([values], cancel=True)
+ line.with_context(**internal_context(SCHEDULE_UPDATE)).write(
+ {'state': 'draft', 'move_id': False, 'payment_date': False})
+ line._compute_withholding()
+ if loan.loan_type == 'lease':
+ loan.message_post(body=self.env._('Bill of the lease payment %s cancelled.', line.sequence))
+ else:
+ loan.message_post(body=self.env._('Payment of the instalment %s cancelled.', line.sequence))
+ if loan.state == 'closed':
+ loan.state = 'running'
diff --git a/om_account_loan/models/account_loan_operation.py b/om_account_loan/models/account_loan_operation.py
new file mode 100644
index 000000000..84f727ace
--- /dev/null
+++ b/om_account_loan/models/account_loan_operation.py
@@ -0,0 +1,93 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+
+from .loan_context import LOAN_REVERSE, LOAN_WRITE, NO_REGENERATE, internal, internal_context
+
+# what a posted operation is booked with, changed by the loan only
+POSTED_FIELDS = {'loan_id', 'operation_type', 'date', 'amount', 'interest', 'withholding', 'journal_id', 'move_id',
+ 'state', 'after_repayment', 'payment_count_before'}
+
+
+class AccountLoanOperation(models.Model):
+ _name = 'account.loan.operation'
+ _description = 'Loan Drawdown, Early Repayment or Lease Recognition'
+ _order = 'loan_id, date, id'
+ _check_company_auto = True
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True, ondelete='cascade', index=True)
+ company_id = fields.Many2one(related='loan_id.company_id', store=True, index=True)
+ currency_id = fields.Many2one(related='loan_id.currency_id', store=True)
+ operation_type = fields.Selection(
+ [('drawdown', 'Drawdown'), ('recognition', 'Initial Recognition'), ('repayment', 'Early Repayment')],
+ string='Type', required=True, readonly=True)
+ date = fields.Date(string='Date', required=True, readonly=True)
+ amount = fields.Monetary(string='Principal', readonly=True)
+ interest = fields.Monetary(string='Interest', readonly=True, help="The interest paid with an early repayment.")
+ withholding = fields.Monetary(
+ string='Withholding Tax', readonly=True, help="The tax withheld on the interest of an early repayment.")
+ total = fields.Monetary(string='Total', compute='_compute_total')
+ journal_id = fields.Many2one('account.journal', string='Journal', readonly=True, check_company=True)
+ move_id = fields.Many2one('account.move', string='Journal Entry', readonly=True, check_company=True)
+ after_repayment = fields.Selection(
+ [('term', 'Shorter Term'), ('instalment', 'Lower Instalment')], string='Then', readonly=True)
+ payment_count_before = fields.Integer(readonly=True, help="The number of payments before a shorter term.")
+ state = fields.Selection(
+ [('posted', 'Posted'), ('cancel', 'Cancelled')], string='Status', required=True, default='posted',
+ readonly=True)
+
+ @api.depends('amount', 'interest')
+ def _compute_total(self):
+ for operation in self:
+ operation.total = operation.amount + operation.interest
+
+ def write(self, vals):
+ if POSTED_FIELDS & set(vals) and not internal(self.env, LOAN_WRITE) \
+ and self.filtered(lambda op: op.state == 'posted'):
+ raise UserError(self.env._('A posted operation of a loan is changed by cancelling it on the loan.'))
+ return super().write(vals)
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_posted(self):
+ if self.filtered(lambda op: op.state == 'posted'):
+ raise UserError(self.env._('Cancel the operation instead of deleting it.'))
+
+ def action_cancel(self):
+ """ Reverse the entry of the operation and compute the schedule again without it. """
+ for operation in self.filtered(lambda op: op.state == 'posted'):
+ loan = operation.loan_id
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted' and line.date >= operation.date)
+ if paid:
+ raise UserError(self.env._(
+ 'Cancel the payment of the instalments due after %s first.', format_date(self.env, operation.date)))
+ later = loan.operation_ids.filtered(
+ lambda op: op.state == 'posted' and (op.date, op.id) > (operation.date, operation.id))
+ if later:
+ raise UserError(self.env._('Cancel the later operations of the loan first.'))
+ if operation.operation_type in ('drawdown', 'recognition'):
+ if loan.currency_id.compare_amounts(loan.outstanding_amount, operation.amount) < 0:
+ raise UserError(self.env._('Without the drawdown, the loan would repay more than it draws.'))
+ move = operation.move_id
+ if move:
+ loan._check_lock_dates(move.date, move.journal_id, has_tax=bool(move.line_ids.tax_line_id))
+ move.with_context(**internal_context(LOAN_REVERSE))._reverse_moves([{'date': move.date}], cancel=True)
+ operation.with_context(**internal_context(LOAN_WRITE)).state = 'cancel'
+ if operation.after_repayment == 'term' and operation.payment_count_before:
+ loan.with_context(**internal_context(NO_REGENERATE)).payment_count = operation.payment_count_before
+ if loan.state == 'closed':
+ loan.state = 'running'
+ loan.message_post(body=self.env._(
+ '%(operation)s of %(date)s cancelled.',
+ operation=dict(self._fields['operation_type'].selection)[operation.operation_type],
+ date=format_date(self.env, operation.date)))
+ loan._generate_schedule()
+ loan._update_state()
+
+ def action_open_move(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'view_mode': 'form',
+ 'res_id': self.move_id.id,
+ }
diff --git a/om_account_loan/models/account_loan_rate.py b/om_account_loan/models/account_loan_rate.py
new file mode 100644
index 000000000..6a2b14c8f
--- /dev/null
+++ b/om_account_loan/models/account_loan_rate.py
@@ -0,0 +1,74 @@
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.tools.misc import format_date
+
+
+class AccountLoanRate(models.Model):
+ _name = 'account.loan.rate'
+ _description = 'Loan Rate Change'
+ _order = 'loan_id, date, id'
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True, ondelete='cascade', index=True)
+ company_id = fields.Many2one(related='loan_id.company_id', store=True, index=True)
+ date = fields.Date(string='From', required=True, help="The first day of the new rate.")
+ rate = fields.Float(string='Annual Rate (%)', digits=(16, 4), required=True)
+
+ _unique_date = models.Constraint('UNIQUE(loan_id, date)', 'A loan has one rate change a day.')
+ _positive_rate = models.Constraint('CHECK(rate >= 0)', 'The interest rate cannot be negative.')
+
+ @api.constrains('loan_id')
+ def _check_not_a_lease(self):
+ for change in self:
+ if change.loan_id.loan_type == 'lease':
+ raise ValidationError(self.env._(
+ 'The rate of the lease %(lease)s cannot change: its liability is measured at that rate.',
+ lease=change.loan_id.display_name))
+
+ @api.constrains('date', 'loan_id')
+ def _check_after_paid(self):
+ """ A rate cannot change in the periods already paid: their interest is booked. """
+ for change in self:
+ paid = change.loan_id.line_ids.filtered(lambda line: line.state == 'posted')
+ last = max(paid.mapped('date'), default=None)
+ if last and change.date <= last:
+ raise ValidationError(self.env._(
+ 'The loan %(loan)s is paid up to %(date)s: a rate change applies from a later date.',
+ loan=change.loan_id.display_name, date=format_date(self.env, last)))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ changes = super().create(vals_list)
+ changes._regenerate(min(changes.mapped('date'), default=None))
+ for change in changes.filtered(lambda change: change.loan_id.state != 'draft'):
+ change.loan_id.message_post(body=self.env._(
+ 'Rate change: %(rate)s %% from %(date)s.', rate=change._format_rate(),
+ date=format_date(self.env, change.date)))
+ return changes
+
+ def write(self, vals):
+ dates = self.mapped('date')
+ res = super().write(vals)
+ self._regenerate(min(dates + self.mapped('date'), default=None))
+ if {'date', 'rate'} & set(vals):
+ for change in self.filtered(lambda change: change.loan_id.state != 'draft'):
+ change.loan_id.message_post(body=self.env._(
+ 'Rate change modified: %(rate)s %% from %(date)s.', rate=change._format_rate(),
+ date=format_date(self.env, change.date)))
+ return res
+
+ def _format_rate(self):
+ return f'{self.rate:.4f}'.rstrip('0').rstrip('.')
+
+ def unlink(self):
+ loans = self.loan_id
+ dates = self.mapped('date')
+ for change in self.filtered(lambda change: change.loan_id.state != 'draft'):
+ change.loan_id.message_post(body=self.env._(
+ 'Rate change of %(date)s removed (%(rate)s %%).', rate=change._format_rate(),
+ date=format_date(self.env, change.date)))
+ res = super().unlink()
+ loans._regenerate_from(min(dates, default=None))
+ return res
+
+ def _regenerate(self, date_from):
+ self.loan_id._regenerate_from(date_from)
diff --git a/om_account_loan/models/account_move.py b/om_account_loan/models/account_move.py
new file mode 100644
index 000000000..1e9243fb9
--- /dev/null
+++ b/om_account_loan/models/account_move.py
@@ -0,0 +1,117 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+from .loan_context import LOAN_REVERSE, LOAN_WRITE, internal, internal_context
+
+LOAN_ENTRY_TYPES = [
+ ('drawdown', 'Drawdown'),
+ ('recognition', 'Lease Recognition'),
+ ('payment', 'Instalment'),
+ ('bill', 'Lease Bill'),
+ ('repayment', 'Early Repayment'),
+ ('reclassification', 'Reclassification'),
+ ('exchange', 'Exchange Difference'),
+]
+# the entries cancelled from the loan only, which follows them in its schedule
+SCHEDULE_ENTRY_TYPES = ('drawdown', 'recognition', 'payment', 'bill', 'repayment', 'exchange')
+# the entries repaying principal in a foreign currency: the exchange difference on it is realized
+REPAYING_ENTRY_TYPES = ('payment', 'bill', 'repayment', 'reclassification')
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ loan_id = fields.Many2one(
+ 'account.loan', string='Loan', readonly=True, copy=False, index='btree_not_null', check_company=True,
+ groups='account.group_account_readonly,account.group_account_manager')
+ loan_entry_type = fields.Selection(
+ LOAN_ENTRY_TYPES, string='Loan Entry', readonly=True, copy=False,
+ groups='account.group_account_readonly,account.group_account_manager')
+ loan_exchange_move_id = fields.Many2one(
+ 'account.move', string='Loan Exchange Difference', readonly=True, copy=False, index='btree_not_null',
+ groups='account.group_account_readonly,account.group_account_manager',
+ help="The exchange difference realized on the principal this entry repays, in a foreign currency loan.")
+
+ loan_reclassified_amount = fields.Monetary(
+ string='Moved to Short Term', compute='_compute_loan_reclassified_amount',
+ groups='account.group_account_readonly,account.group_account_manager',
+ help="The principal a reclassification moved to the short-term account; negative, back to the long-term "
+ "account.")
+
+ @api.depends('line_ids.amount_currency', 'loan_entry_type')
+ def _compute_loan_reclassified_amount(self):
+ for move in self:
+ amount = 0.0
+ if move.loan_entry_type == 'reclassification' and move.loan_id:
+ short_term = move.loan_id.account_short_term_id
+ amount = -sum(move.line_ids.filtered(lambda line: line.account_id == short_term).mapped(
+ 'amount_currency'))
+ move.loan_reclassified_amount = amount
+
+ def _loan_schedule_entries(self):
+ return self.sudo().filtered(lambda move: move.loan_entry_type in SCHEDULE_ENTRY_TYPES)
+
+ def write(self, vals):
+ if {'loan_id', 'loan_entry_type'} & set(vals) and not internal(self.env, LOAN_WRITE) \
+ and self.sudo().filtered(lambda move: move.loan_id and move.state == 'posted'):
+ raise UserError(self.env._('A posted entry of a loan stays linked to its loan.'))
+ return super().write(vals)
+
+ def _post(self, soft=True):
+ # a lease bill left in draft is posted as its lease payment says
+ for bill in self.sudo().filtered(lambda move: move.loan_entry_type == 'bill' and move.state == 'draft'):
+ line = self.env['account.loan.line'].sudo().search([('move_id', '=', bill.id)], limit=1)
+ if line:
+ line._check_bill(bill)
+ posted = super()._post(soft=soft)
+ # the principal repaid in a foreign currency leaves the liability at its carrying rate
+ for move in posted.sudo().filtered(lambda move: (
+ move.loan_entry_type in REPAYING_ENTRY_TYPES and not move.reversed_entry_id
+ and move.loan_id.currency_id != move.company_id.currency_id)):
+ move.loan_id._settle_exchange(move)
+ return posted
+
+ def _loan_needs_document_number(self):
+ """ Whether the bill needs a document number entered by hand before it is posted: the journals using
+ documents (the localizations of Latin America) ask for the number of the lessor's document. """
+ self.ensure_one()
+ journal = self.journal_id
+ return bool(journal._fields.get('l10n_latam_use_documents') and journal.l10n_latam_use_documents)
+
+ def _check_draftable(self):
+ super()._check_draftable()
+ if self._loan_schedule_entries() and not internal(self.env, LOAN_REVERSE):
+ raise UserError(self.env._(
+ 'This entry belongs to the schedule of a loan: cancel it from the loan.'))
+
+ def button_cancel(self):
+ if self._loan_schedule_entries() and not internal(self.env, LOAN_REVERSE):
+ raise UserError(self.env._(
+ 'This entry belongs to the schedule of a loan: cancel it from the loan.'))
+ return super().button_cancel()
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_loan_schedule(self):
+ if self._loan_schedule_entries() and not internal(self.env, LOAN_REVERSE):
+ raise UserError(self.env._(
+ 'This entry belongs to the schedule of a loan: cancel it from the loan.'))
+
+ def _reverse_moves(self, default_values_list=None, cancel=False):
+ if self._loan_schedule_entries() and not internal(self.env, LOAN_REVERSE):
+ raise UserError(self.env._(
+ 'This entry belongs to the schedule of a loan: cancel it from the loan.'))
+ default_values_list = default_values_list or [{} for _move in self]
+ # the exchange difference of an entry goes with it
+ exchanges = self.sudo().loan_exchange_move_id.filtered(lambda move: move.state == 'posted')
+ if exchanges:
+ exchanges.with_context(**internal_context(LOAN_REVERSE))._reverse_moves(
+ [{'date': move.date} for move in exchanges], cancel=True)
+ reversals = super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
+ # the reversal stays in the balances of the loan; written by the loan, as the user reversing an entry
+ # (an invoicing user reversing a reclassification) may not see the loan fields
+ for reversal in reversals.sudo():
+ original = reversal.reversed_entry_id
+ if original.loan_id:
+ reversal.with_context(**internal_context(LOAN_WRITE)).write({
+ 'loan_id': original.loan_id.id, 'loan_entry_type': original.loan_entry_type})
+ return reversals
diff --git a/om_account_loan/models/loan_context.py b/om_account_loan/models/loan_context.py
new file mode 100644
index 000000000..d47c0e700
--- /dev/null
+++ b/om_account_loan/models/loan_context.py
@@ -0,0 +1,27 @@
+""" Markers of the operations the loans run themselves, set in the context.
+
+The value is an object, not a boolean: a client (the web client, RPC) can only send JSON values in its context,
+so it cannot pass itself off as the loan and skip the protections of the schedule (Odoo marks its own lock date
+bypass the same way).
+"""
+
+LOAN_INTERNAL = object()
+
+# the entries of the schedule are reversed or cancelled by the loan
+LOAN_REVERSE = 'loan_reverse'
+# the loan writes its schedule lines, paid ones included
+SCHEDULE_UPDATE = 'loan_schedule_update'
+# the loan writes its own terms, without computing the schedule again
+NO_REGENERATE = 'loan_no_regenerate'
+# the loan writes its operations and links its entries
+LOAN_WRITE = 'loan_write'
+
+
+def internal(env, key):
+ """ Whether the loan itself runs the operation `key` (a marker set by `internal_context`). """
+ return env.context.get(key) is LOAN_INTERNAL
+
+
+def internal_context(*keys):
+ """ The context values marking the operations `keys` as run by the loan. """
+ return dict.fromkeys(keys, LOAN_INTERNAL)
diff --git a/om_account_loan/models/res_company.py b/om_account_loan/models/res_company.py
new file mode 100644
index 000000000..518fde970
--- /dev/null
+++ b/om_account_loan/models/res_company.py
@@ -0,0 +1,29 @@
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ loan_auto_reclassify = fields.Boolean(
+ string='Reclassify the Loans Monthly',
+ help="At the beginning of each month, reclassify the running loans and leases at the end of the month "
+ "before: the principal due within 12 months goes to the short-term accounts.")
+ loan_long_term_account_id = fields.Many2one(
+ 'account.account', string='Long-term Loan Account', check_company=True)
+ loan_short_term_account_id = fields.Many2one(
+ 'account.account', string='Short-term Loan Account', check_company=True)
+ loan_interest_account_id = fields.Many2one(
+ 'account.account', string='Loan Interest Account', check_company=True)
+ loan_journal_id = fields.Many2one(
+ 'account.journal', string='Loan Journal', check_company=True, domain="[('type', '=', 'general')]")
+ loan_bank_journal_id = fields.Many2one(
+ 'account.journal', string='Loan Bank Journal', check_company=True,
+ domain="[('type', 'in', ('bank', 'cash'))]")
+ lease_long_term_account_id = fields.Many2one(
+ 'account.account', string='Long-term Lease Liability Account', check_company=True)
+ lease_short_term_account_id = fields.Many2one(
+ 'account.account', string='Short-term Lease Liability Account', check_company=True)
+ lease_rou_account_id = fields.Many2one(
+ 'account.account', string='Right-of-use Asset Account', check_company=True)
+ lease_bill_journal_id = fields.Many2one(
+ 'account.journal', string='Lease Bill Journal', check_company=True, domain="[('type', '=', 'purchase')]")
diff --git a/om_account_loan/models/res_config_settings.py b/om_account_loan/models/res_config_settings.py
new file mode 100644
index 000000000..a4e5f782c
--- /dev/null
+++ b/om_account_loan/models/res_config_settings.py
@@ -0,0 +1,21 @@
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ loan_auto_reclassify = fields.Boolean(related='company_id.loan_auto_reclassify', readonly=False)
+ loan_long_term_account_id = fields.Many2one(
+ related='company_id.loan_long_term_account_id', readonly=False)
+ loan_short_term_account_id = fields.Many2one(
+ related='company_id.loan_short_term_account_id', readonly=False)
+ loan_interest_account_id = fields.Many2one(
+ related='company_id.loan_interest_account_id', readonly=False)
+ loan_journal_id = fields.Many2one(related='company_id.loan_journal_id', readonly=False)
+ loan_bank_journal_id = fields.Many2one(related='company_id.loan_bank_journal_id', readonly=False)
+ lease_long_term_account_id = fields.Many2one(
+ related='company_id.lease_long_term_account_id', readonly=False)
+ lease_short_term_account_id = fields.Many2one(
+ related='company_id.lease_short_term_account_id', readonly=False)
+ lease_rou_account_id = fields.Many2one(related='company_id.lease_rou_account_id', readonly=False)
+ lease_bill_journal_id = fields.Many2one(related='company_id.lease_bill_journal_id', readonly=False)
diff --git a/om_account_loan/report/__init__.py b/om_account_loan/report/__init__.py
new file mode 100644
index 000000000..39b0038f6
--- /dev/null
+++ b/om_account_loan/report/__init__.py
@@ -0,0 +1 @@
+from . import account_loan_report
diff --git a/om_account_loan/report/account_loan_analysis_views.xml b/om_account_loan/report/account_loan_analysis_views.xml
new file mode 100644
index 000000000..eb71bc565
--- /dev/null
+++ b/om_account_loan/report/account_loan_analysis_views.xml
@@ -0,0 +1,54 @@
+
+
+
+
+ account.loan.line.pivot
+ account.loan.line
+
+
+
+
+
+
+
+
+
+
+
+
+ account.loan.line.graph
+ account.loan.line
+
+
+
+
+
+
+
+
+
+
+
+ Loans Analysis
+ account.loan.line
+ loans-analysis
+ pivot,graph,list
+
+
+ [('loan_id.state', 'in', ('running', 'closed'))]
+
+
No running loan or lease yet
+
The principal, the interest and the instalments of the loans and the leases, year by year over their
+ life: what is paid and what is still to pay. The amounts are in the currency of each loan: group by
+ Currency when the loans are in several currencies.
+
+
+
+
+
+
diff --git a/om_account_loan/report/account_loan_report.py b/om_account_loan/report/account_loan_report.py
new file mode 100644
index 000000000..dcd260408
--- /dev/null
+++ b/om_account_loan/report/account_loan_report.py
@@ -0,0 +1,135 @@
+from odoo import api, fields, models
+from odoo.fields import Command
+from odoo.tools.misc import format_date
+
+from ..models.account_loan import LOAN_TYPES
+
+
+class AccountLoanReport(models.TransientModel):
+ _name = 'account.loan.report'
+ _description = 'Loan Report'
+
+ date = fields.Date(string='At', required=True, default=fields.Date.context_today)
+ company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
+ loan_type = fields.Selection(LOAN_TYPES, string='Type', help="Leave empty for the loans and the leases.")
+ loan_ids = fields.Many2many(
+ 'account.loan', string='Loans',
+ domain="[('company_id', '=', company_id), ('state', '!=', 'cancel')]"
+ " + ([('loan_type', '=', loan_type)] if loan_type else [])",
+ help="Leave empty for every loan (or lease) of the company.")
+ line_ids = fields.One2many('account.loan.report.line', 'report_id', string='Lines')
+
+ def _get_loans(self):
+ self.ensure_one()
+ if self.loan_ids:
+ return self.loan_ids
+ domain = [('company_id', '=', self.company_id.id), ('state', 'in', ('running', 'closed')),
+ ('date_start', '<=', self.date)]
+ if self.loan_type:
+ domain.append(('loan_type', '=', self.loan_type))
+ return self.env['account.loan'].search(domain)
+
+ def _compute_lines(self):
+ self.ensure_one()
+ self.line_ids = [Command.clear()] + [
+ Command.create(self.env['account.loan.report.line']._values_at(loan, self.date))
+ for loan in self._get_loans()
+ ]
+
+ def action_view(self):
+ self._compute_lines()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Loans and Leases at %s', format_date(self.env, self.date)),
+ 'res_model': 'account.loan.report.line',
+ 'view_mode': 'list,pivot',
+ 'domain': [('report_id', '=', self.id)],
+ 'context': {'create': False, 'edit': False, 'delete': False},
+ }
+
+ def action_print(self):
+ self._compute_lines()
+ return self.env.ref('om_account_loan.action_report_loan_position').report_action(self)
+
+
+class AccountLoanReportLine(models.TransientModel):
+ _name = 'account.loan.report.line'
+ _description = 'Loan Report Line'
+ _order = 'report_id, date_start, loan_id'
+
+ report_id = fields.Many2one('account.loan.report', required=True, ondelete='cascade', index=True)
+ date = fields.Date(related='report_id.date', string='At')
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True, ondelete='cascade')
+ loan_type = fields.Selection(LOAN_TYPES, string='Type')
+ partner_id = fields.Many2one('res.partner', string='Lender')
+ company_id = fields.Many2one('res.company', string='Company')
+ currency_id = fields.Many2one('res.currency', string='Currency')
+ company_currency_id = fields.Many2one(related='company_id.currency_id', string='Company Currency')
+ date_start = fields.Date(string='Start Date')
+ rate = fields.Float(string='Rate (%)', digits=(16, 4), aggregator=None)
+ # in the currency of the loan: not added up, the loans may be in several currencies
+ principal = fields.Monetary(string='Principal', aggregator=None)
+ outstanding = fields.Monetary(string='Outstanding Principal', aggregator=None)
+ accrued_interest = fields.Monetary(string='Accrued Interest', aggregator=None)
+ next_date = fields.Date(string='Next Due Date')
+ next_amount = fields.Monetary(string='Next Instalment', aggregator=None)
+ due_within = fields.Monetary(string='Due within 12 Months', aggregator=None)
+ due_after = fields.Monetary(string='Due after 12 Months', aggregator=None)
+ withheld = fields.Monetary(
+ string='Tax Withheld', aggregator=None,
+ help="The tax withheld on the interest paid (or billed) up to the date.")
+ # in the company currency: the totals
+ outstanding_company = fields.Monetary(
+ string='Outstanding (Company Currency)', currency_field='company_currency_id',
+ help="The principal outstanding at its value in the books: the rates of the drawdowns and payments.")
+ due_within_company = fields.Monetary(
+ string='Due within 12 Months (Company Currency)', currency_field='company_currency_id',
+ help="The part of the outstanding in the books due within 12 months.")
+ due_after_company = fields.Monetary(
+ string='Due after 12 Months (Company Currency)', currency_field='company_currency_id')
+ accrued_interest_company = fields.Monetary(
+ string='Accrued Interest (Company Currency)', currency_field='company_currency_id',
+ help="The accrued interest at the rate of the date.")
+ withheld_company = fields.Monetary(
+ string='Tax Withheld (Company Currency)', currency_field='company_currency_id')
+
+ @api.model
+ def _values_at(self, loan, day):
+ """ The position of `loan` on `day`. """
+ outstanding = loan._outstanding_at(day)
+ within = loan._principal_due_within(day)
+ following = loan._lines_unpaid_at(day)[:1]
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted' and line.payment_date <= day)
+ repaid = loan.operation_ids.filtered(
+ lambda op: op.state == 'posted' and op.operation_type == 'repayment' and op.date <= day)
+ booked = sum(
+ loan._account_balance_at(account, day, company_currency=True)
+ for account in loan.account_long_term_id | loan.account_short_term_id)
+ company = loan.company_id
+ company_currency = company.currency_id
+ accrued = loan._accrued_interest_at(day) if outstanding else 0.0
+ withheld = sum(paid.mapped('withholding')) + sum(repaid.mapped('withholding'))
+ # the split of the outstanding in the books follows the split of the principal
+ within_company = company_currency.round(booked * within / outstanding) if outstanding else 0.0
+ return {
+ 'loan_id': loan.id,
+ 'loan_type': loan.loan_type,
+ 'partner_id': loan.partner_id.id,
+ 'company_id': loan.company_id.id,
+ 'currency_id': loan.currency_id.id,
+ 'date_start': loan.date_start,
+ 'rate': loan._rate_at(day),
+ 'principal': loan.principal,
+ 'outstanding': outstanding,
+ 'accrued_interest': accrued,
+ 'next_date': following.date,
+ 'next_amount': following.amount,
+ 'due_within': within,
+ 'due_after': loan.currency_id.round(outstanding - within),
+ 'outstanding_company': booked,
+ 'due_within_company': within_company,
+ 'due_after_company': company_currency.round(booked - within_company),
+ 'accrued_interest_company': loan.currency_id._convert(accrued, company_currency, company, day),
+ 'withheld': withheld,
+ 'withheld_company': loan.currency_id._convert(withheld, company_currency, company, day),
+ }
diff --git a/om_account_loan/report/account_loan_report_views.xml b/om_account_loan/report/account_loan_report_views.xml
new file mode 100644
index 000000000..e0b0682bb
--- /dev/null
+++ b/om_account_loan/report/account_loan_report_views.xml
@@ -0,0 +1,102 @@
+
+
+
+
+ account.loan.report.form
+ account.loan.report
+
+
+
+
+
+
+ Loans and Leases
+ account.loan.report
+ form
+ new
+
+
+
+ account.loan.report.line.list
+ account.loan.report.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account.loan.report.line.pivot
+ account.loan.report.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account.loan.report.line.search
+ account.loan.report.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/om_account_loan/report/report_loan_templates.xml b/om_account_loan/report/report_loan_templates.xml
new file mode 100644
index 000000000..e7efa7170
--- /dev/null
+++ b/om_account_loan/report/report_loan_templates.xml
@@ -0,0 +1,232 @@
+
+
+
+
+ Loan and Schedule
+ account.loan
+ qweb-pdf
+ om_account_loan.report_loan
+ '%s - %s' % ('Lease' if object.loan_type == 'lease' else 'Loan', object.name.replace('/', '-'))
+
+ report
+
+
+
+
+ Loans and Leases (A4 landscape)
+ A4
+ Landscape
+ 20
+ 15
+ 7
+ 7
+
+ 12
+ 90
+
+
+
+ Loans and Leases
+ account.loan.report
+ qweb-pdf
+ om_account_loan.report_loan_position
+ 'Loans and Leases at %s' % object.date
+
+
+
+
+
+
+
The loans your company receives from banks and other lenders, and the leases it holds, in Odoo 20 Community: the contract, its amortization schedule, the drawdowns, the instalments, early repayments, floating rates, the lease liability with the bills of the lessor, the reclassification between short and long term, and the reports. Every step posts its journal entry.
+
+Amortization schedule
+Drawdowns in tranches
+Fixed or floating rate
+Early repayment
+Leases and lessor bills
+Right-of-use asset
+Short / long term
+Loans Analysis
+
Where it is.Invoicing › Accounting › Loans: Loans, Leases, Instalments and Reclassification; the reports in Invoicing › Reporting › Management. The Accounting menu shows to the users with Show Full Accounting Features, as for the other accounting apps (see Install).
+
+
+
Loans · 01
+
A loan and its amortization schedule
+
Enter the terms of the contract and confirm: the schedule is computed, line by line, with the opening balance, the principal, the interest, the instalment and the closing balance of each due date.
+
+
+
The contract
+
+
+
✓
+
The lender and its reference, the principal, the currency, the start date and the first payment date.
+
+
+
✓
+
The number of payments, monthly, quarterly, semi-annual or annual.
+
+
+
✓
+
A fixed or a floating annual rate.
+
+
+
✓
+
Numbered LOAN/2026/0001, from Draft to Running, then Closed (or Cancelled).
+
+
+
✓
+
The accounts and journals come from the settings and can be changed per loan.
+
+
+
The schedule
+
+
+
✓
+
Equal instalments (annuity), fixed principal, or the schedule of the lender imported from a CSV or XLSX file.
+
+
+
✓
+
Interest at the rate divided by the payments per year, or on the actual days / 365.
+
+
+
✓
+
Due dates follow the calendar: a first payment on the 31st keeps the last day of each month.
+
+
+
✓
+
The unpaid lines can be changed by hand (date, principal, interest): the balances follow and the last line repays what is left.
+
+
+
✓
+
The last line takes the rounding, so the principal is repaid to the cent.
+
+
+
+
+
+
+
Example
+
+
+
Loan
+
100,000.00 at 6% over 12 monthly payments
+
+
+
Instalment
+
8,606.64, the last one 8,606.69
+
+
+
First instalment
+
8,106.64 of principal and 500.00 of interest
+
+
+
+
+
+
ACTION
+
JOURNAL
+
ENTRY
+
+
+
Drawdown
+
Bank
+
Dr Outstanding Receipts / Cr Long-term loan
+
+
+
Instalment
+
Bank
+
Dr Short-term, then Long-term loan (principal) + Dr Interest expense / Cr Outstanding Payments
+
+
+
Early repayment
+
Bank
+
as an instalment, with the interest due to the date
+
+
+
Reclassification
+
Loan journal
+
Dr Long-term loan / Cr Short-term loan (or the reverse)
+
+
+
Every entry has the lender as partner. The bank leg goes to the outstanding account of the bank journal, matched with the bank statement line when it comes in; or straight to the bank account with Post Bank Leg On = Bank Account.
+
+
+
+
+
+
A 250,000.00 loan at 7.2% over 48 months: nine instalments paid, then lower instalments after an early repayment of 50,000.00.
+
+
+
Loans · 02
+
Drawdowns, instalments and early repayment
+
Each event of the loan is one click, one entry, and the schedule follows: the Operations tab keeps the drawdowns and repayments with their journal entries.
+
+
+
Drawdowns
+
+
+
✓
+
Draw the loan down in one or more tranches; each drawdown posts its entry.
+
+
+
✓
+
Before the first drawdown the schedule is a projection; after each one, the unpaid lines repay the principal actually drawn.
+
+
+
Instalments
+
+
+
✓
+
Pay from the next line of the schedule: the principal, the interest and the date can be adjusted to what the bank charged.
+
+
+
✓
+
Accounting › Loans › Instalments lists what is due within 30 days, for every loan and lease.
+
+
+
✓
+
The last paid instalment, a drawdown or a repayment can be cancelled from the loan: its entry is reversed on its date and the schedule computed again.
+
+
+
+
+
Early repayment
+
+
+
✓
+
In part or in full, with the interest due up to the repayment date.
+
+
+
✓
+
Then choose a Shorter Term (about the same instalments, fewer of them) or a Lower Instalment (same end date).
+
+
+
✓
+
A full repayment closes the loan.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The Operations tab: the drawdown and the early repayment, with their entries.
+
+
+
+
Accounting › Loans › Instalments: Pay the loan instalments, Bill the lease payments.
+
+
+
Loans · 03
+
Fixed or floating rate
+
Most bank loans follow a reference rate. Record each new rate when the bank announces it: the schedule takes it from its date.
+
+
+
+
+
✓
+
A floating rate changes on its dated rate changes; the current rate shows on the loan.
+
+
+
✓
+
The unpaid lines due from the date of a change are computed again; a period takes the rate in force on its first day.
+
+
+
✓
+
With the actual-days method, a change in the middle of a period splits it at the change.
+
+
+
✓
+
Changing the rate or the number of payments of a running loan computes the unpaid lines again.
+
+
+
✓
+
A rate change is dated after the last paid instalment: the paid lines never move.
+
+
+
+
+
+
+
In the demo
+
+
+
Initial rate
+
6.00% from April 2026
+
+
+
Rate change
+
6.50% from August 1
+
+
+
Rate change
+
6.25% from December 1
+
+
+
Effect
+
next instalment 3,717.23, then 3,705.98
+
+
+
+
+
+
+
+
+
+
Leases · 04
+
Leases: the liability and the bills of the lessor
+
The lessee side of a lease, in the way of IFRS 16: a right-of-use asset against a lease liability at the present value of the payments, each payment split into the finance charge and the reduction of the liability.
+
+
+
The lease
+
+
+
✓
+
Enter the lessor, the lease payment (taxes excluded), the number and frequency of the payments, the first payment date and the rate of the lease.
+
+
+
✓
+
The lease liability is computed: the present value of the payments at that rate, so that the payments repay it exactly.
+
+
+
✓
+
Paid in arrears, or in advance (the first payment on the start date).
+
+
+
✓
+
Confirming the lease recognizes it: Dr Right-of-use asset / Cr Long-term lease liability.
+
+
+
✓
+
Numbered LEASE/2026/0001, under Accounting › Loans › Leases.
+
+
+
+
+
The bills of the lessor
+
+
+
✓
+
Each payment is a vendor bill of the lessor, dated on its due date, in the purchase journal: one line for the principal (the lease liability), one for the interest.
+
+
+
✓
+
The Bill button of a line, Create Bills on several, or a daily scheduled action on the due dates.
+
+
+
✓
+
The taxes of the bills (such as the VAT) on every line; the bills posted, or left in draft to check.
+
+
+
✓
+
The bill is paid like any vendor bill; its status shows on the schedule (To Bill, Billed, Paid).
+
+
+
+
+
Example
+
+
+
Lease
+
12 monthly payments of 1,000.00 at 6%
+
+
+
Lease liability
+
11,618.93 (11,677.03 paid in advance)
+
+
+
First bill
+
941.91 of liability + 58.09 of interest
+
+
+
+
+
+
+
+
A lease of 36 monthly payments of 2,450.00 at 6%: eight bills paid, two billed, the next one to bill.
+
+
+
+
The bill of a lease payment: the principal on the lease liability, the interest on the interest expense, the VAT of the lease on both.
+
+
+
+
The Accounting tab of a lease: accounts, journals, how the bills are created and taxed.
+
+
+
Leases · 05
+
The right-of-use asset
+
+
+
+
+
✓
+
Without Assets Management, the recognition debits the Right-of-use Asset Account of the lease (from the settings).
+
+
+
✓
+
With Odoo 20 Assets Management, the bridge Leases: Right-of-use Assets installs by itself: choose an asset category on the lease and the asset is created at the lease liability and depreciated over the lease term.
+
+
+
✓
+
The asset shows on the lease (Right of Use button); cancelling the recognition cancels it.
+
+
+
+
+
A lease is not a loan. It is not drawn down nor repaid early, it has a fixed rate and equal payments, and its terms do not change once it is recognized. If its recognition is cancelled (Operations tab), Recognize posts it again.
+
+
+
+
+
Balance sheet · 06
+
Current and non-current, always right
+
The balance sheet splits a loan between the part due within a year and the rest. The reclassification keeps that split right at every period end.
+
+
+
+
+
✓
+
At a period end, Accounting › Loans › Reclassification leaves on the short-term account the principal due within the next 12 months, for each loan and lease.
+
+
+
✓
+
It takes the earlier reclassifications and the instalments into account and posts only the difference, in either direction: run it every month or every year.
+
+
+
✓
+
Or tick Reclassify the Loans Monthly in the settings: at the beginning of each month a scheduled action reclassifies at the end of the month before.
+
+
+
✓
+
A loan that cannot be reclassified (a locked period, a lease bill left in draft) says why in its chatter.
+
+
+
+
+
+
+
+
+
+
+
+
Reporting · 07
+
Where every loan stands, at any date
+
In Invoicing › Reporting › Management: Loans and Leases and Loans Analysis.
+
+
+
Loans and Leases report
+
+
+
✓
+
At any date, per loan or lease: the outstanding principal, the interest accrued since the last due date, the next due date and amount, the principal due within 12 months and after.
+
+
+
✓
+
All the loans and leases, one type, or the ones you pick; on screen as a list or a pivot, or as a PDF.
+
+
+
✓
+
Totals in the company currency, at the value in the books.
+
+
+
+
+
Loans Analysis and the loan PDF
+
+
+
✓
+
Loans Analysis: the principal, the interest and the instalments of the running and closed loans and leases, year by year, as a pivot table or a graph.
+
+
+
✓
+
Each loan prints with its terms, its position and its whole schedule (Print › Loan and Schedule).
+
+
+
✓
+
Every change of the terms is tracked in the chatter of the loan.
+
+
+
+
+
+
+
+
The Loans and Leases report at a date, on screen.
+
+
+
+
The same report in PDF (A4 landscape).
+
+
+
+
A loan printed with its schedule.
+
+
+
+
Loans Analysis: what is paid and what is still to pay, year by year, loans and leases side by side.
+
+
+
And also · 08
+
Built for the way accountants work
+
+
+
Withholding tax on the interest
+
Where the law asks the borrower to withhold a tax on the interest: set a tax of -1% (for example) on the loan. The schedule shows the tax and the net payment, the payment posts it on the account of the tax with its tax grids, the lease bills carry it on the interest line.
+
+
+
Imported schedules
+
The lender gives its own schedule? Import it from a CSV (comma, semicolon or tab) or an XLSX file: Date, Principal, Interest, or Date, Instalment, Interest. The file is checked (dates, amounts, total principal) and the unpaid lines replaced.
+
+
+
Foreign currencies
+
A loan or lease in another currency is posted in its currency with the value in the company currency. Each repayment of principal at another rate posts its exchange difference; a loan repaid in full is back to zero in both currencies.
+
+
+
Safe by design
+
Record rules per company; accountants run the loans, administrators delete them and set the defaults, auditors read them. Paid instalments and loan entries are changed only by cancelling them on the loan, and an entry in a locked period is refused.
+
+
+
+
+
Loans vs leases
+
One app, two kinds of contracts
+
Loans and leases share the schedule, the reports and the reclassification. What differs:
+
+
+
+
LOANS
+
LEASES
+
+
+
Menu and numbering
+
Accounting › Loans › Loans; LOAN/2026/0001
+
Accounting › Loans › Leases; LEASE/2026/0001
+
+
+
What you enter
+
The principal
+
The lease payment; the lease liability is computed
+
+
+
Schedule
+
Equal instalments, fixed principal or imported
+
Equal payments
+
+
+
Rate
+
Fixed or floating, with dated rate changes
+
Fixed (implicit rate or incremental borrowing rate)
+
+
+
Money in
+
Drawdowns, in one or more tranches
+
None: Dr Right-of-use asset / Cr Lease liability on confirmation
+
+
+
Each payment
+
Paid from the schedule: a bank entry
+
A vendor bill of the lessor, then paid as any bill
+
+
+
Early repayment
+
In part or in full
+
—
+
+
+
Right-of-use asset
+
—
+
On its account, or created and depreciated with Assets Management
+
+
+
Short / long term
+
Reclassification
+
Reclassification
+
+
+
Withholding tax
+
On the payment of the interest
+
On the interest line of the bill
+
+
+
Reports
+
Loans and Leases, Loans Analysis, PDF
+
Loans and Leases, Loans Analysis, PDF
+
+
+
Cash forecast
+
Unpaid instalments (with the bridge)
+
Payments not yet billed (with the bridge)
+
+
+
+
+
Configuration
+
Set the defaults once
+
+
+
+
+
✓
+
In Invoicing › Configuration › Settings, section Loans: the long-term and short-term loan accounts, the interest expense account, the loan journal and the bank journal.
+
+
+
✓
+
For the leases: the long-term and short-term lease liability accounts (the loan accounts when empty), the right-of-use asset account and the purchase journal of the bills.
+
+
+
✓
+
Tick Reclassify the Loans Monthly for the automatic month-end reclassification.
+
+
+
✓
+
For a withholding tax, create a tax with a negative amount and the account of the tax withheld.
+
+
+
✓
+
Check that the payment methods of the bank journal have their outstanding receipts and payments accounts.
+
+
+
+
+
+
+
+
+
+
Already using Odoo Mates apps?
+
Add the bridge for each app you have
+
Loans and Leases comes with two bridge apps, released with it. A bridge links Loans and Leases with another Odoo Mates app: add the one for each app you already use. A bridge also installs by itself when both apps it links are installed.
The unpaid instalments of the loans and the lease payments not yet billed, in the forecast on their due dates.
✓
Take the latest Cash Forecast too: this release adds to it what the bridge needs (the rows of the forecast are extendable). Download it again, replace its folder and upgrade it, before installing the bridge.
The app is free, for Odoo 20.0 Community. It needs only the Invoicing app (account) and no extra Python library (XLSX schedules are read with openpyxl, a requirement of Odoo itself).
+
+
+
1
+
From this page
+
+
+
Click Download at the top of this page.
+
Unzip it into a folder of your Odoo addons path.
+
Restart the Odoo server.
+
+
+
+
+
2
+
From GitHub
+
+
+
Clone the 20.0 branch with the command below, or download it as a zip.
+
Add the folder to the addons path (or copy only om_account_loan and the modules it needs).
+
Restart the server.
+
+
+
+
+
3
+
Install in Odoo
+
+
+
Turn on the developer mode.
+
Apps › Update Apps List.
+
Remove the Apps filter, search om_account_loan, click Activate.
The Loans menu is under Invoicing › Accounting, which shows to the users with Show Full Accounting Features: tick it on the user (Settings › Users, in developer mode), as for the other accounting apps. With Odoo 20 Accounting Community every accountant has it.
+
The bridges install by themselves: Leases: Right-of-use Assets when Assets Management is installed too, Loans and Leases in the Cash Forecast when Cash Forecast is. If those apps are already installed, install the bridge from the Apps menu. Upgrading? Replace the folder, restart the server, then click Upgrade on the app.
+
Questions, a bank that computes differently, a feature you need: write to odoomates@gmail.com or open an issue on GitHub.
diff --git a/om_account_loan/static/description/instalments.png b/om_account_loan/static/description/instalments.png
new file mode 100644
index 000000000..3d98ff2d9
Binary files /dev/null and b/om_account_loan/static/description/instalments.png differ
diff --git a/om_account_loan/static/description/lease_accounting.png b/om_account_loan/static/description/lease_accounting.png
new file mode 100644
index 000000000..d252bd767
Binary files /dev/null and b/om_account_loan/static/description/lease_accounting.png differ
diff --git a/om_account_loan/static/description/lease_bill.png b/om_account_loan/static/description/lease_bill.png
new file mode 100644
index 000000000..9df867978
Binary files /dev/null and b/om_account_loan/static/description/lease_bill.png differ
diff --git a/om_account_loan/static/description/lease_schedule.png b/om_account_loan/static/description/lease_schedule.png
new file mode 100644
index 000000000..c5c2a97c2
Binary files /dev/null and b/om_account_loan/static/description/lease_schedule.png differ
diff --git a/om_account_loan/static/description/loan_early_repayment.png b/om_account_loan/static/description/loan_early_repayment.png
new file mode 100644
index 000000000..313c9e170
Binary files /dev/null and b/om_account_loan/static/description/loan_early_repayment.png differ
diff --git a/om_account_loan/static/description/loan_floating_rate.png b/om_account_loan/static/description/loan_floating_rate.png
new file mode 100644
index 000000000..e6be2bcca
Binary files /dev/null and b/om_account_loan/static/description/loan_floating_rate.png differ
diff --git a/om_account_loan/static/description/loan_operations.png b/om_account_loan/static/description/loan_operations.png
new file mode 100644
index 000000000..68beb02a9
Binary files /dev/null and b/om_account_loan/static/description/loan_operations.png differ
diff --git a/om_account_loan/static/description/loan_pay.png b/om_account_loan/static/description/loan_pay.png
new file mode 100644
index 000000000..9c9affad2
Binary files /dev/null and b/om_account_loan/static/description/loan_pay.png differ
diff --git a/om_account_loan/static/description/loan_pdf.png b/om_account_loan/static/description/loan_pdf.png
new file mode 100644
index 000000000..a6480293f
Binary files /dev/null and b/om_account_loan/static/description/loan_pdf.png differ
diff --git a/om_account_loan/static/description/loan_report.png b/om_account_loan/static/description/loan_report.png
new file mode 100644
index 000000000..7ad98408c
Binary files /dev/null and b/om_account_loan/static/description/loan_report.png differ
diff --git a/om_account_loan/static/description/loan_report_pdf.png b/om_account_loan/static/description/loan_report_pdf.png
new file mode 100644
index 000000000..97a929261
Binary files /dev/null and b/om_account_loan/static/description/loan_report_pdf.png differ
diff --git a/om_account_loan/static/description/loan_schedule.png b/om_account_loan/static/description/loan_schedule.png
new file mode 100644
index 000000000..4996c429c
Binary files /dev/null and b/om_account_loan/static/description/loan_schedule.png differ
diff --git a/om_account_loan/static/description/loans_analysis.png b/om_account_loan/static/description/loans_analysis.png
new file mode 100644
index 000000000..416d37fd1
Binary files /dev/null and b/om_account_loan/static/description/loans_analysis.png differ
diff --git a/om_account_loan/static/description/odoo_mates.png b/om_account_loan/static/description/odoo_mates.png
new file mode 100644
index 000000000..8408fa3a8
Binary files /dev/null and b/om_account_loan/static/description/odoo_mates.png differ
diff --git a/om_account_loan/static/description/reclassification.png b/om_account_loan/static/description/reclassification.png
new file mode 100644
index 000000000..3600f72d0
Binary files /dev/null and b/om_account_loan/static/description/reclassification.png differ
diff --git a/om_account_loan/static/description/settings.png b/om_account_loan/static/description/settings.png
new file mode 100644
index 000000000..0e28de655
Binary files /dev/null and b/om_account_loan/static/description/settings.png differ
diff --git a/om_account_loan/static/tests/tours/lease_tour.js b/om_account_loan/static/tests/tours/lease_tour.js
new file mode 100644
index 000000000..328b83227
--- /dev/null
+++ b/om_account_loan/static/tests/tours/lease_tour.js
@@ -0,0 +1,59 @@
+import { registry } from "@web/core/registry";
+
+// Create a lease, confirm it (its liability is recognized) and bill the first payment.
+registry.category("web_tour.tours").add("om_account_loan_lease_tour", {
+ steps: () => [
+ {
+ content: "create a lease",
+ trigger: ".o_list_button_add",
+ run: "click",
+ },
+ {
+ content: "the lessor",
+ trigger: ".o_field_widget[name=partner_id] input",
+ run: "edit Tour Lessor",
+ },
+ {
+ content: "pick the lessor",
+ trigger: ".o-autocomplete--dropdown-item:contains(Tour Lessor Properties)",
+ run: "click",
+ },
+ {
+ content: "the lease payment",
+ trigger: ".o_field_widget[name=lease_payment] input",
+ run: "edit 1000",
+ },
+ {
+ content: "the number of payments",
+ trigger: ".o_field_widget[name=payment_count] input",
+ run: "edit 12",
+ },
+ {
+ content: "the rate",
+ trigger: ".o_field_widget[name=rate] input",
+ run: "edit 6",
+ },
+ {
+ content: "confirm the lease",
+ trigger: ".o_form_view button[name=action_confirm]",
+ run: "click",
+ },
+ {
+ content: "the lease runs, recognized",
+ trigger: ".o_form_view .o_statusbar_status button.o_arrow_button_current:contains(Running)",
+ },
+ {
+ content: "the recognition entry",
+ trigger: ".o_form_view button[name=action_view_moves]:contains(1)",
+ },
+ {
+ content: "bill the first payment",
+ trigger: ".o_field_widget[name=line_ids] .o_data_row:first-child button[name=action_create_bill]",
+ run: "click",
+ },
+ {
+ content: "the bill of the lessor",
+ trigger: ".o_form_view .o_field_widget[name=partner_id]:contains(Tour Lessor Properties)",
+ },
+ ],
+});
diff --git a/om_account_loan/static/tests/tours/loan_tour.js b/om_account_loan/static/tests/tours/loan_tour.js
new file mode 100644
index 000000000..7ca66b69c
--- /dev/null
+++ b/om_account_loan/static/tests/tours/loan_tour.js
@@ -0,0 +1,91 @@
+import { registry } from "@web/core/registry";
+
+// Create a loan, confirm it, draw it down, compute the schedule and pay the first instalment.
+registry.category("web_tour.tours").add("om_account_loan_tour", {
+ steps: () => [
+ {
+ content: "create a loan",
+ trigger: ".o_list_button_add",
+ run: "click",
+ },
+ {
+ content: "the lender",
+ trigger: ".o_field_widget[name=partner_id] input",
+ run: "edit Tour Lending",
+ },
+ {
+ content: "pick the lender",
+ trigger: ".o-autocomplete--dropdown-item:contains(Tour Lending Bank)",
+ run: "click",
+ },
+ {
+ content: "the principal",
+ trigger: ".o_field_widget[name=principal] input",
+ run: "edit 24000",
+ },
+ {
+ content: "the number of payments",
+ trigger: ".o_field_widget[name=payment_count] input",
+ run: "edit 24",
+ },
+ {
+ content: "the rate",
+ trigger: ".o_field_widget[name=rate] input",
+ run: "edit 7.5",
+ },
+ {
+ content: "confirm the loan",
+ trigger: ".o_form_view button[name=action_confirm]",
+ run: "click",
+ },
+ {
+ content: "the loan runs, with its schedule",
+ trigger: ".o_form_view .o_statusbar_status button.o_arrow_button_current:contains(Running)",
+ },
+ {
+ content: "the projected schedule is listed",
+ trigger: ".o_field_widget[name=line_ids] .o_data_row:nth-child(24)",
+ },
+ {
+ content: "draw it down",
+ trigger: ".o_form_view button[name=action_drawdown]",
+ run: "click",
+ },
+ {
+ content: "the whole principal is proposed",
+ trigger: ".modal .o_field_widget[name=amount] input:value(24,000.00)",
+ },
+ {
+ content: "post the drawdown",
+ trigger: ".modal button[name=action_confirm]",
+ run: "click",
+ },
+ {
+ content: "the principal is drawn",
+ trigger: "body:not(:has(.modal)) .o_form_view .o_field_widget[name=outstanding_amount]:contains(24,000.00)",
+ },
+ {
+ content: "compute the schedule",
+ trigger: ".o_form_view button[name=action_compute_schedule]",
+ run: "click",
+ },
+ {
+ content: "pay the first instalment",
+ trigger: ".o_field_widget[name=line_ids] .o_data_row:first-child button[name=action_pay]",
+ run: "click",
+ },
+ {
+ content: "confirm the payment",
+ trigger: ".modal button[name=action_confirm]",
+ run: "click",
+ },
+ {
+ content: "the first instalment is paid",
+ trigger: "body:not(:has(.modal)) .o_field_widget[name=line_ids] .o_data_row:first-child .o_field_widget[name=state]:contains(Paid)",
+ },
+ {
+ content: "the entries of the loan",
+ trigger: ".o_form_view button[name=action_view_moves]:contains(2)",
+ },
+ ],
+});
diff --git a/om_account_loan/tests/__init__.py b/om_account_loan/tests/__init__.py
new file mode 100644
index 000000000..d805e67d2
--- /dev/null
+++ b/om_account_loan/tests/__init__.py
@@ -0,0 +1,10 @@
+from . import test_schedule
+from . import test_entries
+from . import test_report_security
+from . import test_lease
+from . import test_withholding
+from . import test_import
+from . import test_tour
+from . import test_protection
+from . import test_automation
+from . import test_tracking
diff --git a/om_account_loan/tests/common.py b/om_account_loan/tests/common.py
new file mode 100644
index 000000000..cdf9d969e
--- /dev/null
+++ b/om_account_loan/tests/common.py
@@ -0,0 +1,147 @@
+import base64
+from datetime import date
+
+from odoo import Command
+
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+class LoanCommon(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ Account = cls.env['account.account']
+ cls.long_term_account = Account.create({
+ 'code': '171900', 'name': 'Bank Loans (non-current)', 'account_type': 'liability_non_current'})
+ cls.short_term_account = Account.create({
+ 'code': '161900', 'name': 'Bank Loans (current)', 'account_type': 'liability_current'})
+ cls.interest_account = Account.create({
+ 'code': '661900', 'name': 'Interest on Loans', 'account_type': 'expense'})
+ cls.bank_journal = cls.company_data['default_journal_bank']
+ cls.misc_journal = cls.company_data['default_journal_misc']
+ cls.outstanding_receipts = cls.inbound_payment_method_line.payment_account_id
+ cls.outstanding_payments = cls.outbound_payment_method_line.payment_account_id
+ cls.env.company.write({
+ 'loan_long_term_account_id': cls.long_term_account.id,
+ 'loan_short_term_account_id': cls.short_term_account.id,
+ 'loan_interest_account_id': cls.interest_account.id,
+ 'loan_journal_id': cls.misc_journal.id,
+ 'loan_bank_journal_id': cls.bank_journal.id,
+ })
+ cls.lender = cls.env['res.partner'].create({'name': 'Test Bank', 'is_company': True})
+ # leases
+ cls.lease_long_term_account = Account.create({
+ 'code': '172900', 'name': 'Lease Liabilities (non-current)', 'account_type': 'liability_non_current'})
+ cls.lease_short_term_account = Account.create({
+ 'code': '162900', 'name': 'Lease Liabilities (current)', 'account_type': 'liability_current'})
+ cls.rou_account = Account.create({
+ 'code': '152900', 'name': 'Right-of-use Assets', 'account_type': 'asset_non_current'})
+ cls.purchase_journal = cls.company_data['default_journal_purchase']
+ cls.env.company.write({
+ 'lease_long_term_account_id': cls.lease_long_term_account.id,
+ 'lease_short_term_account_id': cls.lease_short_term_account.id,
+ 'lease_rou_account_id': cls.rou_account.id,
+ 'lease_bill_journal_id': cls.purchase_journal.id,
+ })
+ cls.lessor = cls.env['res.partner'].create({'name': 'Test Properties', 'is_company': True})
+ # a withholding tax of 1% on the interest
+ cls.withholding_account = Account.create({
+ 'code': '213900', 'name': 'Withholding Tax Payable', 'account_type': 'liability_current'})
+ cls.withholding_tax = cls._create_withholding_tax(-1.0)
+ cls.vat_account = Account.create({
+ 'code': '114900', 'name': 'Input VAT on Leases', 'account_type': 'asset_current'})
+ cls.vat_tax = cls._create_withholding_tax(7.0, name='Lease VAT 7%', account=cls.vat_account)
+
+ @classmethod
+ def _create_withholding_tax(cls, amount, name='WHT 1% interest', account=None):
+ """ A purchase tax of `amount` % booked on `account`, the withholding tax account by default. """
+ account = account or cls.withholding_account
+
+ def repartition():
+ return [
+ Command.create({'repartition_type': 'base'}),
+ Command.create({'repartition_type': 'tax', 'account_id': account.id}),
+ ]
+ return cls.env['account.tax'].create({
+ 'name': name,
+ 'amount_type': 'percent',
+ 'amount': amount,
+ 'type_tax_use': 'purchase',
+ 'invoice_repartition_line_ids': repartition(),
+ 'refund_repartition_line_ids': repartition(),
+ })
+
+ @classmethod
+ def _create_lease(cls, confirm=True, **vals):
+ """ 12 monthly lease payments of 1,000.00 in arrears at 6%: a liability of 11,618.93. """
+ lease = cls.env['account.loan'].create({
+ 'loan_type': 'lease',
+ 'partner_id': cls.lessor.id,
+ 'lease_payment': 1000.0,
+ 'date_start': date(2025, 1, 1),
+ 'date_first_payment': date(2025, 2, 1),
+ 'payment_count': 12,
+ 'rate': 6.0,
+ 'bill_auto_create': False,
+ **vals,
+ })
+ if confirm:
+ lease.action_confirm()
+ return lease
+
+ @classmethod
+ def _import(cls, loan, content, filename='schedule.csv', columns='principal'):
+ if isinstance(content, str):
+ content = content.encode()
+ wizard = cls.env['account.loan.import'].create({
+ 'loan_id': loan.id, 'file': base64.b64encode(content).decode(), 'filename': filename, 'columns': columns})
+ wizard.action_import()
+ return wizard
+
+ @classmethod
+ def _create_loan(cls, confirm=True, **vals):
+ loan = cls.env['account.loan'].create({
+ 'partner_id': cls.lender.id,
+ 'principal': 100000.0,
+ 'date_start': date(2025, 1, 1),
+ 'date_first_payment': date(2025, 2, 1),
+ 'payment_count': 12,
+ 'rate': 6.0,
+ **vals,
+ })
+ if confirm:
+ loan.action_confirm()
+ return loan
+
+ @classmethod
+ def _draw(cls, loan, day=None, amount=None, **vals):
+ wizard = cls.env['account.loan.drawdown'].create({
+ 'loan_id': loan.id, 'date': day or loan.date_start, **({'amount': amount} if amount else {}), **vals})
+ wizard.action_confirm()
+ return loan.operation_ids.filtered(lambda op: op.operation_type == 'drawdown').sorted('id')[-1]
+
+ @classmethod
+ def _pay(cls, line, day=None, **vals):
+ wizard = cls.env['account.loan.pay'].create({'line_id': line.id, **vals})
+ if day:
+ wizard.date = day
+ wizard.action_confirm()
+ return line.move_id
+
+ @classmethod
+ def _repay(cls, loan, day, amount, after='instalment', **vals):
+ wizard = cls.env['account.loan.repay'].create({
+ 'loan_id': loan.id, 'date': day, 'amount': amount, 'after_repayment': after, **vals})
+ wizard.action_confirm()
+ return loan.operation_ids.filtered(lambda op: op.operation_type == 'repayment').sorted('id')[-1]
+
+ def _lines(self, loan):
+ return loan.line_ids.sorted('sequence')
+
+ def _entry(self, move):
+ """ The lines of `move` as {account code: (debit, credit, amount in currency)} """
+ return {
+ line.account_id.code: (line.debit, line.credit, line.amount_currency)
+ for line in move.line_ids
+ }
diff --git a/om_account_loan/tests/test_automation.py b/om_account_loan/tests/test_automation.py
new file mode 100644
index 000000000..69e0e2d51
--- /dev/null
+++ b/om_account_loan/tests/test_automation.py
@@ -0,0 +1,47 @@
+from datetime import date
+
+from freezegun import freeze_time
+
+from odoo.tests import tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanAutomation(LoanCommon):
+
+ def _reclassifications(self, loan):
+ return loan.move_ids.filtered(lambda move: move.loan_entry_type == 'reclassification')
+
+ @freeze_time('2025-04-03')
+ def test_monthly_reclassification(self):
+ """ The companies that ask for it have their running loans reclassified at the end of the month
+ before, once. """
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ self.env['account.loan']._cron_reclassify()
+ self.assertFalse(self._reclassifications(loan), "the company did not ask for it")
+ self.env.company.loan_auto_reclassify = True
+ self.env['account.loan']._cron_reclassify()
+ entry = self._reclassifications(loan)
+ self.assertEqual(entry.date, date(2025, 3, 31))
+ self.assertEqual(loan._account_balance_at(self.short_term_account, date(2025, 3, 31)),
+ loan._principal_due_within(date(2025, 3, 31)))
+ self.env['account.loan']._cron_reclassify()
+ self.assertEqual(self._reclassifications(loan), entry, "once a month end")
+
+ @freeze_time('2025-04-03')
+ def test_monthly_reclassification_failure(self):
+ """ A loan that cannot be reclassified says why, once, and is reclassified when it can. """
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ self.env.company.loan_auto_reclassify = True
+ self.env.company.fiscalyear_lock_date = date(2025, 3, 31)
+ messages = len(loan.message_ids)
+ self.env['account.loan']._cron_reclassify()
+ self.env['account.loan']._cron_reclassify()
+ self.assertFalse(self._reclassifications(loan))
+ self.assertEqual(len(loan.message_ids), messages + 1)
+ self.env.company.fiscalyear_lock_date = False
+ self.env['account.loan']._cron_reclassify()
+ self.assertEqual(self._reclassifications(loan).date, date(2025, 3, 31))
diff --git a/om_account_loan/tests/test_entries.py b/om_account_loan/tests/test_entries.py
new file mode 100644
index 000000000..77c65a659
--- /dev/null
+++ b/om_account_loan/tests/test_entries.py
@@ -0,0 +1,357 @@
+from datetime import date
+
+from odoo import Command
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanEntries(LoanCommon):
+
+ def test_drawdown_entry(self):
+ """ Dr outstanding receipts of the bank journal / Cr long-term loan. """
+ loan = self._create_loan()
+ operation = self._draw(loan, date(2025, 1, 1), 100000.0)
+ move = operation.move_id
+ self.assertEqual(move.state, 'posted')
+ self.assertEqual(move.journal_id, self.bank_journal)
+ self.assertEqual(move.date, date(2025, 1, 1))
+ self.assertEqual((move.loan_id, move.loan_entry_type), (loan, 'drawdown'))
+ self.assertEqual(self._entry(move), {
+ self.outstanding_receipts.code: (100000.0, 0.0, 100000.0),
+ self.long_term_account.code: (0.0, 100000.0, -100000.0),
+ })
+ self.assertEqual(move.line_ids.partner_id, self.lender)
+ self.assertEqual((loan.drawn_amount, loan.undrawn_amount, loan.outstanding_amount), (100000.0, 0.0, 100000.0))
+ with self.assertRaises(UserError):
+ self._draw(loan, date(2025, 1, 5), 1.0)
+
+ def test_drawdown_without_outstanding_account(self):
+ """ A bank journal without outstanding accounts on its methods takes those of the chart, as payments do;
+ the bank posting option goes straight to the bank account. """
+ self.bank_journal.inbound_payment_method_line_ids.payment_account_id = False
+ loan = self._create_loan()
+ move = self._draw(loan).move_id
+ chart_account = self.env['account.chart.template'].ref('account_journal_payment_debit_account_id')
+ self.assertIn(chart_account, move.line_ids.account_id)
+ loan = self._create_loan(bank_posting='bank')
+ move = self._draw(loan).move_id
+ self.assertIn(self.bank_journal.default_account_id, move.line_ids.account_id)
+
+ def test_payment_entry(self):
+ """ Dr long-term loan (principal) + Dr interest / Cr outstanding payments. """
+ loan = self._create_loan()
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ move = self._pay(line)
+ self.assertEqual(line.state, 'posted')
+ self.assertEqual(line.payment_date, date(2025, 2, 1))
+ self.assertEqual((move.journal_id, move.date, move.loan_entry_type), (self.bank_journal, line.date, 'payment'))
+ self.assertEqual(self._entry(move), {
+ self.long_term_account.code: (8106.64, 0.0, 8106.64),
+ self.interest_account.code: (500.0, 0.0, 500.0),
+ self.outstanding_payments.code: (0.0, 8606.64, -8606.64),
+ })
+ self.assertEqual(loan.outstanding_amount, 91893.36)
+ self.assertEqual(loan.next_line_id, self._lines(loan)[1])
+
+ def test_statement_matches_outstanding(self):
+ """ The bank statement line of the payment is matched with the outstanding line of the entry. """
+ loan = self._create_loan()
+ self._draw(loan)
+ move = self._pay(self._lines(loan)[0])
+ outstanding = move.line_ids.filtered(lambda aml: aml.account_id == self.outstanding_payments)
+ st_line = self.env['account.bank.statement.line'].create({
+ 'journal_id': self.bank_journal.id, 'date': date(2025, 2, 3), 'payment_ref': loan.name,
+ 'partner_id': self.lender.id, 'amount': -8606.64})
+ self.assertIn(outstanding, self.env['account.move.line'].search(st_line._get_default_amls_matching_domain()))
+ _liquidity, suspense, _other = st_line._seek_for_lines()
+ st_line.move_id.button_draft()
+ suspense.account_id = self.outstanding_payments
+ st_line.move_id.action_post()
+ (suspense | outstanding).reconcile()
+ self.assertTrue(st_line.is_reconciled)
+ self.assertTrue(outstanding.reconciled)
+
+ def test_payment_order_and_lock(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ lines = self._lines(loan)
+ with self.assertRaises(UserError):
+ self._pay(lines[1])
+ move = self._pay(lines[0])
+ with self.assertRaises(UserError):
+ lines[0].principal = 1.0
+ with self.assertRaises(UserError):
+ move.button_draft()
+ with self.assertRaises(UserError):
+ move._reverse_moves()
+ with self.assertRaises(UserError):
+ lines[0].unlink()
+
+ def test_payment_changed_in_wizard(self):
+ """ The interest charged by the bank differs: it is paid as charged, the principal stays. """
+ loan = self._create_loan()
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ move = self._pay(line, interest=512.5)
+ self.assertEqual(line.interest, 512.5)
+ self.assertEqual(sum(move.line_ids.mapped('debit')), 8106.64 + 512.5)
+ self.assertAlmostEqual(sum(loan.line_ids.mapped('principal')), 100000.0, places=2)
+
+ def test_cancel_payment(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ lines = self._lines(loan)
+ move = self._pay(lines[0])
+ self._pay(lines[1])
+ self.assertFalse(lines[0].is_last_paid)
+ with self.assertRaises(UserError):
+ lines[0].action_cancel_payment()
+ lines[1].action_cancel_payment()
+ lines[0].action_cancel_payment()
+ self.assertEqual(lines[0].state, 'draft')
+ self.assertFalse(lines[0].move_id)
+ self.assertEqual(move.reversal_move_ids.loan_id, loan)
+ self.assertEqual(loan.outstanding_amount, 100000.0)
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 12, 31)), 100000.0)
+
+ def test_tranches(self):
+ """ 60,000 drawn, an instalment paid, then 40,000 drawn mid-period: the period counts both balances
+ by days, then the unpaid lines repay the 95,136.01 owed. """
+ loan = self._create_loan()
+ self._draw(loan, date(2025, 1, 1), 60000.0)
+ lines = self._lines(loan)
+ self.assertEqual(lines[0].opening_balance, 60000.0)
+ self.assertEqual((lines[0].interest, lines[0].principal), (300.0, 4863.99))
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 60000.0, places=2)
+ self._pay(lines[0])
+ with self.assertRaises(UserError):
+ self._draw(loan, date(2025, 2, 1), 40000.0)
+ self._draw(loan, date(2025, 2, 15), 40000.0)
+ lines = self._lines(loan)
+ self.assertEqual(len(lines), 12)
+ self.assertEqual(lines[0].principal, 4863.99, "the paid line is kept")
+ self.assertEqual(lines[1].opening_balance, 95136.01)
+ self.assertEqual(lines[1].interest, 375.68)
+ self.assertEqual(lines[1].amount, 8910.35)
+ self.assertEqual(set(lines[1:-1].mapped('amount')), {8910.35})
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+ self.assertEqual(loan.outstanding_amount, 95136.01)
+
+ def test_cancel_drawdown(self):
+ loan = self._create_loan()
+ first = self._draw(loan, date(2025, 1, 1), 60000.0)
+ second = self._draw(loan, date(2025, 1, 10), 40000.0)
+ with self.assertRaises(UserError):
+ first.action_cancel()
+ second.action_cancel()
+ self.assertEqual(second.state, 'cancel')
+ self.assertEqual(loan.drawn_amount, 60000.0)
+ self.assertAlmostEqual(sum(loan.line_ids.mapped('principal')), 60000.0, places=2)
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 1, 31)), 60000.0)
+
+ def _pay_three(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ for line in self._lines(loan)[:3]:
+ self._pay(line)
+ return loan
+
+ def test_early_repayment_lower_instalment(self):
+ loan = self._pay_three()
+ self.assertEqual(loan.outstanding_amount, 75558.28)
+ wizard = self.env['account.loan.repay'].create({'loan_id': loan.id, 'date': date(2025, 4, 15)})
+ # the principal repaid is entered: repaying it all closes the loan
+ self.assertEqual(wizard.amount, 0.0)
+ self.assertEqual(wizard.interest, 176.3, "14 days of the April period")
+ operation = self._repay(loan, date(2025, 4, 15), 20000.0)
+ self.assertEqual(operation.interest, 176.3)
+ self.assertEqual(self._entry(operation.move_id), {
+ self.long_term_account.code: (20000.0, 0.0, 20000.0),
+ self.interest_account.code: (176.3, 0.0, 176.3),
+ self.outstanding_payments.code: (0.0, 20176.3, -20176.3),
+ })
+ lines = self._lines(loan)
+ self.assertEqual(loan.payment_count, 12)
+ self.assertEqual(len(lines), 12)
+ self.assertEqual(lines[3].opening_balance, 55558.28)
+ self.assertEqual(lines[3].interest, 148.16, "interest from the repayment only")
+ self.assertEqual(lines[3].amount, 6328.5)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 80000.0, places=2)
+ self.assertEqual(loan.outstanding_amount, 55558.28)
+ with self.assertRaises(UserError):
+ self._repay(loan, date(2025, 5, 10), 1000.0) # the instalment of 1 May is due
+
+ def test_early_repayment_shorter_term(self):
+ loan = self._pay_three()
+ operation = self._repay(loan, date(2025, 4, 15), 20000.0, after='term')
+ lines = self._lines(loan)
+ self.assertEqual(loan.payment_count, 10)
+ self.assertEqual(len(lines), 10)
+ self.assertEqual(lines[3].amount, 8096.43)
+ self.assertLessEqual(lines[3].amount, 8606.64)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 80000.0, places=2)
+ operation.action_cancel()
+ self.assertEqual(loan.payment_count, 12)
+ self.assertEqual(len(loan.line_ids), 12)
+ self.assertEqual(loan.outstanding_amount, 75558.28)
+
+ def test_full_repayment(self):
+ loan = self._pay_three()
+ self._repay(loan, date(2025, 4, 15), 75558.28)
+ self.assertEqual(loan.state, 'closed')
+ self.assertEqual(len(loan.line_ids), 3)
+ self.assertEqual(loan.outstanding_amount, 0.0)
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 4, 30)), 0.0)
+
+ def test_last_payment_closes(self):
+ loan = self._create_loan(payment_count=2)
+ self._draw(loan)
+ for line in self._lines(loan):
+ self._pay(line)
+ self.assertEqual(loan.state, 'closed')
+ self._lines(loan)[-1].action_cancel_payment()
+ self.assertEqual(loan.state, 'running')
+
+ def test_floating_rate_change(self):
+ """ From 1 July at 8%: the unpaid lines from that date are computed again, the earlier ones stay. """
+ loan = self._create_loan(rate_type='floating')
+ self._draw(loan)
+ before = [(line.principal, line.interest) for line in self._lines(loan)]
+ self._pay(self._lines(loan)[0])
+ loan.rate_line_ids = [Command.create({'date': date(2025, 7, 1), 'rate': 8.0})]
+ lines = self._lines(loan)
+ self.assertEqual([(line.principal, line.interest) for line in lines[:6]], before[:6])
+ balance = lines[5].closing_balance
+ self.assertEqual(lines[6].rate, 8.0)
+ self.assertEqual(lines[6].interest, round(balance * 0.08 / 12, 2))
+ expected = round(balance * (0.08 / 12) / (1 - (1 + 0.08 / 12) ** -6), 2)
+ self.assertEqual(lines[6].amount, expected)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+ self.assertEqual(loan._rate_at(date(2025, 6, 30)), 6.0)
+ self.assertEqual(loan._rate_at(date(2025, 7, 1)), 8.0)
+ loan.rate_line_ids.unlink()
+ lines = self._lines(loan)
+ self.assertEqual(set(lines.mapped('rate')), {6.0})
+ self.assertEqual(lines[6].interest, round(balance * 0.06 / 12, 2))
+ self.assertAlmostEqual(lines[6].amount, 8606.64, delta=0.02)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+
+ def test_floating_rate_actual_days(self):
+ """ With the actual days, a rate changing during a period splits it. """
+ loan = self._create_loan(
+ rate_type='floating', interest_method='actual_365', schedule_method='fixed_principal',
+ date_first_payment=date(2025, 1, 31), rate=5.0)
+ self._draw(loan)
+ loan.rate_line_ids = [Command.create({'date': date(2025, 1, 21), 'rate': 7.3})]
+ # 100,000 at 5% from the end of 1 Jan to the end of 20 Jan, then at 7.3% to the end of 31 Jan
+ self.assertEqual(self._lines(loan)[0].interest, round(100000 * (0.05 * 19 + 0.073 * 11) / 365, 2))
+
+ def test_reclassification(self):
+ """ At two successive dates the short-term account holds the principal due within 12 months. """
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ lines = self._lines(loan)
+ first = self.env['account.loan.reclassify'].create({
+ 'date': date(2025, 1, 31), 'loan_ids': [Command.set(loan.ids)]}).action_confirm()
+ move = self.env['account.move'].search(first['domain'])
+ due = round(sum(lines[:12].mapped('principal')), 2)
+ self.assertEqual((move.journal_id, move.date, move.loan_entry_type),
+ (self.misc_journal, date(2025, 1, 31), 'reclassification'))
+ self.assertEqual(self._entry(move), {
+ self.long_term_account.code: (due, 0.0, due),
+ self.short_term_account.code: (0.0, due, -due),
+ })
+ for line in lines[:3]:
+ payment = self._pay(line)
+ self.assertEqual(self._entry(payment)[self.short_term_account.code][0], lines[2].principal,
+ "the principal reclassified is repaid from the short-term account")
+ self.assertNotIn(self.long_term_account.code, self._entry(payment))
+ self.env['account.loan.reclassify'].create({
+ 'date': date(2025, 4, 30), 'loan_ids': [Command.set(loan.ids)]}).action_confirm()
+ second = loan.move_ids.filtered(lambda m: m.loan_entry_type == 'reclassification' and m != move)
+ self.assertAlmostEqual(sum(second.line_ids.mapped('debit')), sum(lines[12:15].mapped('principal')), places=2)
+ self.assertAlmostEqual(loan._account_balance_at(self.short_term_account, date(2025, 4, 30)),
+ sum(lines[3:15].mapped('principal')), places=2)
+ self.assertAlmostEqual(
+ loan._account_balance_at(self.short_term_account, date(2025, 4, 30))
+ + loan._account_balance_at(self.long_term_account, date(2025, 4, 30)),
+ loan.outstanding_amount, places=2)
+ with self.assertRaises(UserError):
+ # nothing to move again
+ self.env['account.loan.reclassify'].create({
+ 'date': date(2025, 4, 30), 'loan_ids': [Command.set(loan.ids)]}).action_confirm()
+ with self.assertRaises(UserError):
+ loan._reclassify(date(2025, 3, 31))
+ # a reclassification may be reversed in the books: the loan follows it
+ second._reverse_moves([{'date': second.date}], cancel=True)
+ self.assertAlmostEqual(loan._account_balance_at(self.short_term_account, date(2025, 4, 30)),
+ sum(lines[3:12].mapped('principal')), places=2)
+
+ def test_reclassification_near_maturity(self):
+ """ Within the last 12 months the whole principal is current. """
+ loan = self._create_loan()
+ self._draw(loan)
+ move = loan._reclassify(date(2025, 1, 31))
+ self.assertEqual(sum(move.line_ids.mapped('debit')), 100000.0)
+
+ def test_foreign_currency_exchange_difference(self):
+ """ The principal repaid at another rate than the drawdown realizes an exchange difference: the
+ liability keeps its carrying rate, and is back to zero in the company currency once repaid. """
+ # the euro weaker at the first payment, stronger at the second
+ eur = self.setup_other_currency('EUR', rates=[('2025-01-01', 2.0), ('2025-01-15', 2.5), ('2025-02-15', 1.6)])
+ company = self.env.company
+ loan = self._create_loan(currency_id=eur.id, principal=10000.0, payment_count=2)
+ self._draw(loan)
+ first = self._pay(self._lines(loan)[0])
+ exchange = first.loan_exchange_move_id
+ self.assertEqual(exchange.loan_entry_type, 'exchange')
+ self.assertIn(company.income_currency_exchange_account_id, exchange.line_ids.account_id)
+ left = loan._account_balance_at(self.long_term_account, date(2025, 2, 1))
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 2, 1), company_currency=True),
+ eur._convert(left, company.currency_id, company, date(2025, 1, 1)),
+ "the rest of the liability stays at the rate of the drawdown")
+ # repaid in a stronger euro: a loss
+ last = self._pay(self._lines(loan)[1])
+ self.assertTrue(last.loan_exchange_move_id)
+ self.assertIn(company.expense_currency_exchange_account_id, last.loan_exchange_move_id.line_ids.account_id)
+ self.assertEqual(loan.state, 'closed')
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 3, 1), company_currency=True), 0.0)
+ report = self.env['account.loan.report'].create({'date': date(2025, 3, 1)})
+ report._compute_lines()
+ self.assertEqual(report.line_ids.filtered(lambda line: line.loan_id == loan).outstanding_company, 0.0)
+ # cancelling the payment reverses its exchange difference too
+ self._lines(loan)[1].action_cancel_payment()
+ self.assertEqual(last.loan_exchange_move_id.reversal_move_ids.state, 'posted')
+ self.assertEqual(loan._account_balance_at(self.long_term_account, date(2025, 3, 1), company_currency=True),
+ loan._account_balance_at(self.long_term_account, date(2025, 2, 1), company_currency=True))
+
+ def test_foreign_currency(self):
+ """ A loan in a foreign currency posts its amount in currency and the company value of the day. """
+ eur = self.setup_other_currency('EUR', rates=[('2025-01-01', 2.0)])
+ loan = self._create_loan(currency_id=eur.id, principal=10000.0)
+ move = self._draw(loan).move_id
+ self.assertEqual(move.line_ids.currency_id, eur)
+ self.assertEqual(self._entry(move), {
+ self.outstanding_receipts.code: (5000.0, 0.0, 10000.0),
+ self.long_term_account.code: (0.0, 5000.0, -10000.0),
+ })
+ payment = self._pay(self._lines(loan)[0])
+ self.assertEqual(self._entry(payment), {
+ self.long_term_account.code: (405.33, 0.0, 810.66),
+ self.interest_account.code: (25.0, 0.0, 50.0),
+ self.outstanding_payments.code: (0.0, 430.33, -860.66),
+ })
+ report = self.env['account.loan.report'].create({'date': date(2025, 2, 1)})
+ report._compute_lines()
+ line = report.line_ids.filtered(lambda line: line.loan_id == loan)
+ self.assertEqual(line.outstanding, 9189.34)
+ self.assertEqual(line.outstanding_company, 4594.67)
+ bank_gbp = self.env['account.journal'].create({
+ 'name': 'Bank GBP', 'code': 'BGBP', 'type': 'bank',
+ 'currency_id': self.setup_other_currency('GBP').id})
+ with self.assertRaises(UserError):
+ loan.bank_journal_id = bank_gbp
diff --git a/om_account_loan/tests/test_import.py b/om_account_loan/tests/test_import.py
new file mode 100644
index 000000000..fa3f2d84a
--- /dev/null
+++ b/om_account_loan/tests/test_import.py
@@ -0,0 +1,156 @@
+import io
+from datetime import date
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from odoo.addons.om_account_loan.wizard.account_loan_import import parse_amount
+
+from .common import LoanCommon
+
+SCHEDULE = """Date,Principal,Interest
+2025-02-01,30000.00,500.00
+2025-03-01,30000.00,350.00
+2025-04-01,40000.00,200.00
+"""
+
+
+@tagged('post_install', '-at_install')
+class TestImportSchedule(LoanCommon):
+
+ def _imported_loan(self, **vals):
+ return self._create_loan(confirm=False, schedule_method='imported', **vals)
+
+ def test_parse_amount(self):
+ self.assertEqual(parse_amount('1,234.56'), 1234.56)
+ self.assertEqual(parse_amount('1.234,56'), 1234.56)
+ self.assertEqual(parse_amount('1234,5'), 1234.5)
+ self.assertEqual(parse_amount('1,234'), 1234.0)
+ self.assertEqual(parse_amount('1 234,56'), 1234.56)
+ self.assertEqual(parse_amount(''), 0.0)
+ self.assertEqual(parse_amount(12.5), 12.5)
+ with self.assertRaises(ValueError):
+ parse_amount('abc')
+
+ def test_import_csv(self):
+ """ The lines of the file replace the schedule; the loan then runs on them. """
+ loan = self._imported_loan()
+ with self.assertRaises(UserError):
+ loan.action_confirm()
+ self._import(loan, SCHEDULE)
+ lines = self._lines(loan)
+ self.assertEqual(lines.mapped('date'), [date(2025, 2, 1), date(2025, 3, 1), date(2025, 4, 1)])
+ self.assertEqual(lines.mapped('principal'), [30000.0, 30000.0, 40000.0])
+ self.assertEqual(lines.mapped('interest'), [500.0, 350.0, 200.0])
+ self.assertEqual(lines.mapped('opening_balance'), [100000.0, 70000.0, 40000.0])
+ self.assertEqual((loan.payment_count, loan.date_first_payment), (3, date(2025, 2, 1)))
+ loan.action_confirm()
+ self.assertEqual(self._lines(loan).mapped('principal'), [30000.0, 30000.0, 40000.0])
+ self._draw(loan)
+ self.assertEqual(self._lines(loan).mapped('interest'), [500.0, 350.0, 200.0])
+ move = self._pay(self._lines(loan)[0])
+ self.assertEqual(self._entry(move), {
+ self.long_term_account.code: (30000.0, 0.0, 30000.0),
+ self.interest_account.code: (500.0, 0.0, 500.0),
+ self.outstanding_payments.code: (0.0, 30500.0, -30500.0),
+ })
+
+ def test_import_instalments_semicolon(self):
+ """ Date, instalment, interest, with a semicolon, day-first dates and decimal commas. """
+ loan = self._imported_loan()
+ self._import(loan, "01/02/2025;30500,00;500,00\n01/03/2025;30350,00;350,00\n01/04/2025;40200,00;200,00\n",
+ columns='instalment')
+ lines = self._lines(loan)
+ self.assertEqual(lines.mapped('principal'), [30000.0, 30000.0, 40000.0])
+ self.assertEqual(lines.mapped('amount'), [30500.0, 30350.0, 40200.0])
+
+ def test_import_xlsx(self):
+ import openpyxl # the library of the import
+
+ book = openpyxl.Workbook()
+ sheet = book.active
+ sheet.append(['Due date', 'Principal', 'Interest'])
+ sheet.append([date(2025, 2, 1), 50000, 500.0])
+ sheet.append([date(2025, 3, 1), 50000, 250.0])
+ content = io.BytesIO()
+ book.save(content)
+ loan = self._imported_loan()
+ self._import(loan, content.getvalue(), filename='lender.xlsx')
+ self.assertEqual(self._lines(loan).mapped('principal'), [50000.0, 50000.0])
+ self.assertEqual(self._lines(loan).mapped('interest'), [500.0, 250.0])
+
+ def test_import_validation(self):
+ loan = self._imported_loan()
+ cases = {
+ 'sum': "2025-02-01,30000,500\n2025-03-01,30000,350\n",
+ 'dates': "2025-03-01,50000,500\n2025-02-01,50000,350\n",
+ 'before start': "2024-12-01,50000,500\n2025-02-01,50000,350\n",
+ 'not a date': "2025-02-01,50000,500\nnext month,50000,350\n",
+ 'not a number': "2025-02-01,50000,500\n2025-03-01,fifty,350\n",
+ 'negative interest': "2025-02-01,50000,-5\n2025-03-01,50000,350\n",
+ 'empty': "Date,Principal,Interest\n",
+ }
+ for case, content in cases.items():
+ with self.subTest(case=case), self.assertRaises(UserError):
+ self._import(loan, content)
+ with self.assertRaises(UserError):
+ # an instalment lower than its interest
+ self._import(loan, "2025-02-01,400,500\n2025-03-01,100350,350\n", columns='instalment')
+ self.assertFalse(loan.line_ids)
+ # only for an imported schedule
+ with self.assertRaises(UserError):
+ self._import(self._create_loan(confirm=False), SCHEDULE)
+
+ def test_edit_imported_line(self):
+ """ An unpaid imported line is changed by hand, the last one takes the difference. """
+ loan = self._imported_loan()
+ self._import(loan, SCHEDULE)
+ loan.action_confirm()
+ self._draw(loan)
+ lines = self._lines(loan)
+ lines[0].write({'principal': 35000.0, 'interest': 510.0})
+ self.assertEqual(self._lines(loan).mapped('principal'), [35000.0, 30000.0, 35000.0])
+ loan.action_compute_schedule()
+ self.assertEqual(self._lines(loan).mapped('principal'), [35000.0, 30000.0, 35000.0])
+ self.assertEqual(self._lines(loan)[0].interest, 510.0)
+
+ def test_reimport_after_payment(self):
+ """ The paid lines stay; the new file schedules the principal left. """
+ loan = self._imported_loan()
+ self._import(loan, SCHEDULE)
+ loan.action_confirm()
+ self._draw(loan)
+ self._pay(self._lines(loan)[0])
+ with self.assertRaises(UserError):
+ # dated before the paid line
+ self._import(loan, "2025-01-15,70000,300\n")
+ self._import(loan, "2025-03-01,20000,350\n2025-04-01,20000,200\n2025-05-01,30000,100\n")
+ lines = self._lines(loan)
+ self.assertEqual(lines.mapped('sequence'), [1, 2, 3, 4])
+ self.assertEqual(lines.mapped('state'), ['posted', 'draft', 'draft', 'draft'])
+ self.assertEqual(lines[1:].mapped('principal'), [20000.0, 20000.0, 30000.0])
+ self.assertEqual(loan.payment_count, 4)
+
+ def test_early_repayment_shortens(self):
+ """ The principal repaid early comes off the last imported lines. """
+ loan = self._imported_loan()
+ self._import(loan, SCHEDULE)
+ loan.action_confirm()
+ self._draw(loan)
+ self._pay(self._lines(loan)[0])
+ self._repay(loan, date(2025, 2, 15), 50000.0, interest=100.0)
+ lines = self._lines(loan)
+ self.assertEqual(lines.mapped('principal'), [30000.0, 20000.0])
+ self.assertEqual(loan.payment_count, 2)
+ self.assertEqual(loan.outstanding_amount, 20000.0)
+
+ def test_tranches_keep_imported_schedule(self):
+ """ The imported schedule assumes the principal not drawn yet is drawn on the start date. """
+ loan = self._imported_loan()
+ self._import(loan, SCHEDULE)
+ loan.action_confirm()
+ self._draw(loan, date(2025, 1, 1), 60000.0)
+ self.assertEqual(self._lines(loan).mapped('principal'), [30000.0, 30000.0, 40000.0])
+ self._pay(self._lines(loan)[0])
+ self._draw(loan, date(2025, 2, 10), 40000.0)
+ self.assertEqual(self._lines(loan).mapped('principal'), [30000.0, 30000.0, 40000.0])
diff --git a/om_account_loan/tests/test_lease.py b/om_account_loan/tests/test_lease.py
new file mode 100644
index 000000000..b7de234ae
--- /dev/null
+++ b/om_account_loan/tests/test_lease.py
@@ -0,0 +1,249 @@
+from datetime import date
+
+from odoo import Command
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLease(LoanCommon):
+
+ def _payment_term_line(self, bill):
+ return bill.line_ids.filtered(lambda aml: aml.display_type == 'payment_term')
+
+ def test_foreign_currency_lease_settles(self):
+ """ A lease in a foreign currency, billed at other rates than its recognition, leaves no balance on its
+ liability in the company currency once billed. """
+ eur = self.setup_other_currency('EUR', rates=[('2025-01-01', 2.0), ('2025-04-15', 2.5), ('2025-08-15', 1.6)])
+ lease = self._create_lease(currency_id=eur.id)
+ lease._reclassify(date(2025, 1, 31))
+ for line in self._lines(lease):
+ line._create_bill()
+ self.assertEqual(lease.state, 'closed')
+ for account in (self.lease_long_term_account, self.lease_short_term_account):
+ self.assertEqual(lease._account_balance_at(account, date(2026, 1, 31)), 0.0)
+ self.assertEqual(lease._account_balance_at(account, date(2026, 1, 31), company_currency=True), 0.0)
+ exchanges = lease.move_ids.filtered(lambda move: move.loan_entry_type == 'exchange')
+ self.assertTrue(exchanges)
+
+ def test_present_value(self):
+ """ The lease liability is the present value of the payments at the rate of the lease. """
+ lease = self._create_lease(confirm=False)
+ self.assertEqual(lease.principal, 11618.93)
+ self.assertTrue(lease.name.startswith('LEASE/'))
+ self.assertEqual(lease.account_long_term_id, self.lease_long_term_account)
+ self.assertEqual(lease.account_short_term_id, self.lease_short_term_account)
+ lease.rate = 0.0
+ self.assertEqual(lease.principal, 12000.0)
+ lease.rate = 6.0
+ lease.action_compute_schedule()
+ lines = self._lines(lease)
+ self.assertEqual(len(lines), 12)
+ self.assertEqual(set(lines.mapped('amount')), {1000.0})
+ self.assertEqual((lines[0].opening_balance, lines[0].interest, lines[0].principal), (11618.93, 58.09, 941.91))
+ self.assertEqual((lines[1].interest, lines[1].principal), (53.39, 946.61))
+ self.assertEqual((lines[-1].interest, lines[-1].principal, lines[-1].closing_balance), (4.98, 995.02, 0.0))
+ self.assertEqual(lease.lease_end, date(2026, 1, 1))
+
+ def test_paid_in_advance(self):
+ """ A first payment on the start date is not discounted: it repays the liability only. """
+ lease = self._create_lease(date_first_payment=date(2025, 1, 1))
+ self.assertEqual(lease.principal, 11677.03)
+ lines = self._lines(lease)
+ self.assertEqual((lines[0].date, lines[0].interest, lines[0].principal), (date(2025, 1, 1), 0.0, 1000.0))
+ self.assertEqual((lines[1].interest, lines[1].principal), (53.39, 946.61))
+ self.assertEqual(lines[-1].closing_balance, 0.0)
+ self.assertEqual(lease.lease_end, date(2026, 1, 1))
+ bill = lines[0]._create_bill()
+ self.assertEqual(bill.invoice_date, date(2025, 1, 1))
+ self.assertEqual(self._payment_term_line(bill).credit, 1000.0)
+
+ def test_initial_recognition(self):
+ """ Dr right-of-use asset / Cr long-term lease liability at the present value, on the start date. """
+ lease = self._create_lease()
+ operation = lease.operation_ids
+ self.assertEqual((operation.operation_type, operation.amount, operation.date),
+ ('recognition', 11618.93, date(2025, 1, 1)))
+ move = operation.move_id
+ self.assertEqual((move.journal_id, move.date, move.state), (self.misc_journal, date(2025, 1, 1), 'posted'))
+ self.assertEqual((move.loan_id, move.loan_entry_type), (lease, 'recognition'))
+ self.assertEqual(self._entry(move), {
+ self.rou_account.code: (11618.93, 0.0, 11618.93),
+ self.lease_long_term_account.code: (0.0, 11618.93, -11618.93),
+ })
+ self.assertEqual(move.line_ids.partner_id, self.lessor)
+ self.assertEqual(lease.state, 'running')
+ self.assertEqual((lease.drawn_amount, lease.outstanding_amount), (11618.93, 11618.93))
+ with self.assertRaises(UserError):
+ move.button_draft()
+
+ def test_lease_bill(self):
+ """ A due line creates the bill of the lessor: Dr lease liability + Dr interest / Cr payable. """
+ lease = self._create_lease()
+ line = self._lines(lease)[0]
+ action = line.action_create_bill()
+ bill = line.move_id
+ self.assertEqual(action['res_id'], bill.id)
+ self.assertEqual((bill.move_type, bill.state, bill.partner_id), ('in_invoice', 'posted', self.lessor))
+ self.assertEqual((bill.journal_id, bill.invoice_date), (self.purchase_journal, date(2025, 2, 1)))
+ self.assertEqual((bill.loan_id, bill.loan_entry_type), (lease, 'bill'))
+ self.assertEqual(bill.amount_total, 1000.0)
+ products = bill.invoice_line_ids
+ self.assertEqual(
+ sorted((aml.account_id, aml.debit) for aml in products),
+ sorted([(self.lease_long_term_account, 941.91), (self.interest_account, 58.09)]))
+ self.assertEqual(self._payment_term_line(bill).credit, 1000.0)
+ self.assertEqual((line.state, line.payment_date, line.bill_status), ('posted', date(2025, 2, 1), 'posted'))
+ self.assertEqual(lease.outstanding_amount, 10677.02)
+ # in order, and not paid from the bank
+ with self.assertRaises(UserError):
+ self._lines(lease)[2]._create_bill()
+ with self.assertRaises(UserError):
+ self._pay(self._lines(lease)[1])
+ # the bill is paid like any vendor bill
+ self.env['account.payment.register'].with_context(
+ active_model='account.move', active_ids=bill.ids,
+ ).create({'payment_date': date(2025, 2, 5)})._create_payments()
+ self.assertIn(bill.payment_state, ('paid', 'in_payment'))
+ self.assertEqual(line.bill_status, 'paid')
+ # a paid bill is not cancelled from the lease
+ with self.assertRaises(UserError):
+ line.action_cancel_payment()
+
+ def test_bill_taxes(self):
+ """ The taxes of the lease go on the lines of the bill, the withholding tax on the interest only. """
+ lease = self._create_lease(
+ bill_tax_ids=[Command.set(self.vat_tax.ids)], withholding_tax_id=self.withholding_tax.id)
+ line = self._lines(lease)[0]
+ self.assertEqual((line.withholding, line.net_amount), (0.58, 999.42))
+ bill = line._create_bill()
+ vat = bill.line_ids.filtered(lambda aml: aml.tax_line_id == self.vat_tax)
+ withholding = bill.line_ids.filtered(lambda aml: aml.tax_line_id == self.withholding_tax)
+ self.assertEqual((vat.account_id, vat.debit), (self.vat_account, 70.0))
+ self.assertEqual((withholding.account_id, withholding.credit), (self.withholding_account, 0.58))
+ self.assertEqual(bill.amount_total, 1069.42)
+ self.assertEqual(self._payment_term_line(bill).credit, 1069.42)
+ interest_line = bill.invoice_line_ids.filtered(lambda aml: aml.account_id == self.interest_account)
+ self.assertEqual(interest_line.tax_ids, self.vat_tax | self.withholding_tax)
+ principal_line = bill.invoice_line_ids.filtered(lambda aml: aml.account_id == self.lease_long_term_account)
+ self.assertEqual(principal_line.tax_ids, self.vat_tax)
+ self.assertEqual(line.withholding, 0.58)
+
+ def test_draft_bills(self):
+ """ Bills left in draft: posted by hand, cancelled from the lease, posted before a reclassification. """
+ lease = self._create_lease(bill_posting='draft')
+ line = self._lines(lease)[0]
+ bill = line._create_bill()
+ self.assertEqual((bill.state, line.state, line.bill_status), ('draft', 'posted', 'draft'))
+ with self.assertRaises(UserError):
+ bill.button_cancel()
+ with self.assertRaises(UserError):
+ bill.unlink()
+ with self.assertRaises(UserError):
+ lease._reclassify(date(2025, 2, 28))
+ line.action_cancel_payment()
+ self.assertEqual((bill.state, line.state, line.bill_status), ('cancel', 'draft', 'to_bill'))
+ self.assertFalse(line.move_id)
+ bill = line._create_bill()
+ bill.action_post()
+ self.assertEqual(line.bill_status, 'posted')
+ self.assertTrue(lease._reclassify(date(2025, 2, 28)))
+
+ def test_cancel_posted_bill(self):
+ """ The last bill is reversed by a credit note, its line is to bill again. """
+ lease = self._create_lease()
+ line = self._lines(lease)[0]
+ bill = line._create_bill()
+ line.action_cancel_payment()
+ self.assertEqual(line.state, 'draft')
+ self.assertEqual(bill.payment_state, 'reversed')
+ refund = bill.reversal_move_ids
+ self.assertEqual((refund.move_type, refund.loan_id, refund.loan_entry_type), ('in_refund', lease, 'bill'))
+ self.assertEqual((refund.state, refund.invoice_date), ('posted', bill.invoice_date))
+ self.assertEqual(lease.outstanding_amount, 11618.93)
+ self.assertEqual(lease._account_balance_at(self.lease_long_term_account, date(2025, 3, 1)), 11618.93)
+
+ def test_reclassification(self):
+ """ The current part of the lease liability goes to the short-term account; the bills repay it. """
+ lease = self._create_lease(payment_count=24, lease_payment=500.0)
+ move = lease._reclassify(date(2025, 1, 31))
+ within = round(sum(self._lines(lease)[:12].mapped('principal')), 2)
+ self.assertEqual(self._entry(move), {
+ self.lease_long_term_account.code: (within, 0.0, within),
+ self.lease_short_term_account.code: (0.0, within, -within),
+ })
+ line = self._lines(lease)[0]
+ bill = line._create_bill()
+ principal_line = bill.invoice_line_ids.filtered(lambda aml: aml.account_id == self.lease_short_term_account)
+ self.assertEqual(principal_line.debit, line.principal)
+
+ def test_terms_locked(self):
+ lease = self._create_lease()
+ with self.assertRaises(UserError):
+ lease.rate = 7.0
+ # nor by a rate change: the liability is measured at the rate of the lease
+ with self.assertRaises(ValidationError):
+ lease.rate_line_ids = [Command.create({'date': date(2025, 6, 1), 'rate': 7.0})]
+ with self.assertRaises(UserError):
+ lease.lease_payment = 1200.0
+ with self.assertRaises(UserError):
+ self._draw(lease, date(2025, 1, 15), 100.0)
+ with self.assertRaises(UserError):
+ self._repay(lease, date(2025, 1, 15), 100.0)
+
+ def test_configuration_required(self):
+ lease = self._create_lease(confirm=False, account_rou_id=False)
+ with self.assertRaises(UserError):
+ lease.action_confirm()
+ lease.account_rou_id = self.rou_account
+ lease.bill_journal_id = False
+ with self.assertRaises(UserError):
+ lease.action_confirm()
+
+ def test_cancel_recognition(self):
+ """ The recognition is reversed like a drawdown, the lease can be recognized again. """
+ lease = self._create_lease()
+ operation = lease.operation_ids
+ operation.action_cancel()
+ self.assertEqual(operation.state, 'cancel')
+ self.assertEqual(lease.outstanding_amount, 0.0)
+ lease.action_recognize()
+ self.assertEqual(lease.outstanding_amount, 11618.93)
+ with self.assertRaises(UserError):
+ lease.action_recognize()
+
+ def test_cron_bills_due_payments(self):
+ """ The scheduled action bills the lease payments due, in their order; the last one closes the lease. """
+ lease = self._create_lease(bill_auto_create=True)
+ manual = self._create_lease()
+ self.env['account.loan.line']._cron_create_lease_bills()
+ self.assertEqual(set(self._lines(lease).mapped('state')), {'posted'})
+ self.assertEqual(len(lease.move_ids.filtered(lambda move: move.loan_entry_type == 'bill')), 12)
+ self.assertEqual(lease.state, 'closed')
+ self.assertEqual(lease.outstanding_amount, 0.0)
+ self.assertEqual(set(self._lines(manual).mapped('state')), {'draft'})
+
+ def test_create_bills_action(self):
+ lease = self._create_lease()
+ lines = self._lines(lease)[:3]
+ action = lines[::-1].action_create_bills()
+ self.assertEqual(set(lines.mapped('state')), {'posted'})
+ self.assertEqual(len(self.env['account.move'].search(action['domain'])), 3)
+
+ def test_report_types(self):
+ """ The report lists the loans and the leases, or one type. """
+ loan = self._create_loan()
+ self._draw(loan)
+ lease = self._create_lease()
+ report = self.env['account.loan.report'].create({'date': date(2025, 1, 15)})
+ report._compute_lines()
+ self.assertEqual(report.line_ids.loan_id, loan | lease)
+ self.assertEqual(report.line_ids.filtered(lambda line: line.loan_id == lease).loan_type, 'lease')
+ report.loan_type = 'lease'
+ report._compute_lines()
+ self.assertEqual(report.line_ids.loan_id, lease)
+ self.assertEqual(report.line_ids.outstanding, 11618.93)
+ html = self.env['ir.actions.report']._render_qweb_html('om_account_loan.report_loan', lease.ids)[0]
+ self.assertIn(b'Lessor', html)
diff --git a/om_account_loan/tests/test_protection.py b/om_account_loan/tests/test_protection.py
new file mode 100644
index 000000000..663315c0a
--- /dev/null
+++ b/om_account_loan/tests/test_protection.py
@@ -0,0 +1,197 @@
+from datetime import date
+
+from odoo import Command
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import new_test_user, tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanProtection(LoanCommon):
+ """ The schedule protects itself: what the loan posted is changed from the loan only. """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.invoicing = new_test_user(
+ cls.env, login='loan_protect_invoicing', groups='account.group_account_invoice',
+ company_id=cls.env.company.id)
+
+ def _paid_loan(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ self._pay(line)
+ return loan, line
+
+ def test_client_context_does_not_bypass(self):
+ """ The markers of the loan are objects: a context value sent by a client does not unlock its entries. """
+ loan, line = self._paid_loan()
+ move = line.move_id
+ for env_user in (self.env.user, self.invoicing):
+ entry = move.with_user(env_user).with_context(loan_reverse=True)
+ with self.assertRaises(UserError):
+ entry.button_draft()
+ with self.assertRaises(UserError):
+ entry.button_cancel()
+ with self.assertRaises(UserError):
+ line.with_context(loan_schedule_update=True).write({'principal': 1.0})
+ with self.assertRaises(UserError):
+ line.with_context(loan_schedule_update=True).write({'state': 'draft'})
+ self.assertEqual((move.state, line.state, line.move_id), ('posted', 'posted', move))
+
+ def test_entries_stay_linked(self):
+ loan, line = self._paid_loan()
+ with self.assertRaises(UserError):
+ line.move_id.write({'loan_id': False})
+ with self.assertRaises(UserError):
+ loan.write({'move_ids': [Command.clear()]})
+ other = self._create_loan()
+ with self.assertRaises(UserError):
+ line.write({'loan_id': other.id})
+ self.assertTrue(loan._has_entries())
+
+ def test_posted_operation_locked(self):
+ loan = self._create_loan()
+ drawdown = self._draw(loan)
+ for vals in ({'amount': 1.0}, {'state': 'cancel'}, {'date': date(2025, 1, 2)}):
+ with self.assertRaises(UserError):
+ drawdown.write(vals)
+ self.assertEqual(loan.outstanding_amount, 100000.0)
+
+ def test_terms_and_accounts_locked(self):
+ loan = self._create_loan()
+ # running without entries: the terms are fixed, the accounts not yet
+ with self.assertRaises(UserError):
+ loan.principal = 5.0
+ with self.assertRaises(UserError):
+ loan.loan_type = 'lease'
+ loan.account_long_term_id = self.long_term_account
+ self._draw(loan)
+ with self.assertRaises(UserError):
+ loan.account_long_term_id = self.interest_account
+ with self.assertRaises(UserError):
+ loan.partner_id = self.lessor
+ # the rate and the number of payments too: a rate change or an early repayment changes them
+ with self.assertRaises(UserError):
+ loan.rate = 7.0
+ with self.assertRaises(UserError):
+ loan.rate_type = 'floating'
+ with self.assertRaises(UserError):
+ loan.payment_count = 24
+
+ def test_reclassifications_listed_with_the_operations(self):
+ """ The Operations tab lists the reclassifications, with the principal they moved. """
+ loan = self._create_loan()
+ self._draw(loan)
+ move = loan._reclassify(date(2025, 1, 31))
+ self.assertEqual(loan.reclassification_move_ids, move)
+ # the twelve instalments are due within 12 months: the whole principal moves to the short term
+ self.assertEqual(move.loan_reclassified_amount, 100000.0)
+ self.assertFalse(loan.operation_ids.filtered(lambda op: op.move_id == move))
+
+ def test_rate_change_of_a_fixed_rate_loan(self):
+ """ A fixed rate renegotiated with the lender: a dated rate change, the paid instalments stay. """
+ loan, _line = self._paid_loan()
+ before = self._lines(loan)[0].interest
+ loan.rate_line_ids = [Command.create({'date': date(2025, 3, 1), 'rate': 7.5})]
+ lines = self._lines(loan)
+ self.assertEqual(loan.rate_type, 'fixed')
+ self.assertEqual(lines[0].interest, before)
+ self.assertEqual(lines[0].rate, 6.0)
+ self.assertEqual(lines[2].rate, 7.5)
+ self.assertEqual(loan._rate_at(date(2025, 3, 1)), 7.5)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+
+ def test_lock_date_refused(self):
+ """ A loan entry is not posted on another date than its schedule: a locked period is refused. """
+ loan = self._create_loan()
+ self._draw(loan)
+ self.env.company.fiscalyear_lock_date = date(2025, 3, 31)
+ line = self._lines(loan)[0]
+ with self.assertRaises(UserError):
+ self._pay(line)
+ self.assertEqual(line.state, 'draft')
+ with self.assertRaises(UserError):
+ self._draw(self._create_loan(), date(2025, 1, 15))
+ # after the lock date
+ self.env.company.fiscalyear_lock_date = False
+ move = self._pay(line)
+ self.assertEqual(move.date, date(2025, 2, 1))
+ self.env.company.fiscalyear_lock_date = date(2025, 3, 31)
+ with self.assertRaises(UserError):
+ line.action_cancel_payment()
+ self.assertEqual(line.state, 'posted')
+
+ def test_cancelled_drawdown_frees_the_loan(self):
+ loan = self._create_loan()
+ self._draw(loan).action_cancel()
+ self.assertFalse(loan._has_entries())
+ loan.action_cancel()
+ self.assertEqual(loan.state, 'cancel')
+ loan.action_draft()
+ loan.unlink()
+
+ def test_pay_wizard_checks(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ with self.assertRaises(UserError):
+ self._pay(line, interest=-50.0)
+ with self.assertRaises(UserError):
+ self._pay(line, day=date(2024, 12, 31))
+ self._pay(line, day=date(2025, 2, 10))
+ with self.assertRaises(UserError):
+ self._pay(self._lines(loan)[1], day=date(2025, 2, 5))
+ # without a withholding tax, nothing is withheld whatever the wizard says
+ move = self._pay(self._lines(loan)[1], withholding=5.0)
+ self.assertEqual(self._lines(loan)[1].withholding, 0.0)
+ self.assertNotIn(self.withholding_account, move.line_ids.account_id)
+
+ def test_pay_needs_a_drawdown(self):
+ loan = self._create_loan()
+ line = self._lines(loan)[0]
+ self.assertFalse(line.is_next)
+ with self.assertRaises(UserError):
+ self._pay(line)
+ self._draw(loan)
+ self.assertEqual(loan.line_ids.filtered('is_next'), line)
+
+ def test_draft_lease_without_payment(self):
+ lease = self._create_lease(confirm=False, lease_payment=0.0)
+ self.assertEqual(lease.principal, 0.0)
+ with self.assertRaises(UserError):
+ lease.action_confirm()
+ lease.lease_payment = 1000.0
+ lease.action_confirm()
+ self.assertEqual(lease.state, 'running')
+
+ def test_draft_bill_must_match(self):
+ lease = self._create_lease(bill_posting='draft')
+ line = self._lines(lease)[0]
+ bill = line._create_bill()
+ self.assertEqual(bill.state, 'draft')
+ bill.invoice_line_ids[0].price_unit = 1.0
+ with self.assertRaises(UserError):
+ bill.action_post()
+ bill.invoice_line_ids[0].price_unit = line.principal
+ bill.action_post()
+ self.assertEqual(bill.state, 'posted')
+
+ def test_rate_change_after_paid(self):
+ loan = self._create_loan(rate_type='floating')
+ self._draw(loan)
+ self._pay(self._lines(loan)[0])
+ with self.assertRaises(ValidationError):
+ loan.rate_line_ids = [Command.create({'date': date(2025, 1, 15), 'rate': 8.0})]
+ loan.rate_line_ids = [Command.create({'date': date(2025, 2, 2), 'rate': 8.0})]
+ self.assertEqual(self._lines(loan)[1].rate, 8.0)
+
+ def test_reclassify_not_in_the_future(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ wizard = self.env['account.loan.reclassify'].create({
+ 'date': date(2999, 12, 31), 'loan_ids': [Command.set(loan.ids)]})
+ with self.assertRaises(UserError):
+ wizard.action_confirm()
diff --git a/om_account_loan/tests/test_report_security.py b/om_account_loan/tests/test_report_security.py
new file mode 100644
index 000000000..8b09f478a
--- /dev/null
+++ b/om_account_loan/tests/test_report_security.py
@@ -0,0 +1,185 @@
+import ast
+from datetime import date
+
+from odoo import Command
+from odoo.exceptions import AccessError
+from odoo.tests import new_test_user, tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanReport(LoanCommon):
+
+ def _report(self, day, loans=None):
+ report = self.env['account.loan.report'].create({
+ 'date': day, **({'loan_ids': [Command.set(loans.ids)]} if loans else {})})
+ report._compute_lines()
+ return report
+
+ def test_report_values(self):
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ lines = self._lines(loan)
+ for line in lines[:3]:
+ self._pay(line)
+ line = self._report(date(2025, 4, 15), loan).line_ids
+ outstanding = round(100000.0 - sum(lines[:3].mapped('principal')), 2)
+ within = round(sum(lines[3:15].mapped('principal')), 2)
+ self.assertEqual(line.loan_id, loan)
+ self.assertEqual(line.partner_id, self.lender)
+ self.assertEqual(line.outstanding, outstanding)
+ self.assertEqual(line.accrued_interest, round(outstanding * 0.06 / 12 * 14 / 30, 2))
+ self.assertEqual(line.next_date, date(2025, 5, 1))
+ self.assertEqual(line.next_amount, lines[3].amount)
+ self.assertAlmostEqual(line.due_within, within, places=2)
+ self.assertAlmostEqual(line.due_after, outstanding - within, places=2)
+ self.assertEqual(line.outstanding_company, outstanding)
+ self.assertEqual(line.rate, 6.0)
+ # before the payment of an instalment due, it stays the next one
+ line = self._report(date(2025, 5, 10), loan).line_ids
+ self.assertEqual(line.next_date, date(2025, 5, 1))
+ self.assertEqual(line.accrued_interest, round(lines[3].closing_balance * 0.06 / 12 * 9 / 31, 2))
+ # nothing drawn before the start
+ self.assertFalse(self._report(date(2024, 12, 31), loan).line_ids.outstanding)
+
+ def test_report_every_loan(self):
+ loans = self._create_loan() | self._create_loan(principal=50000.0)
+ self._create_loan(confirm=False)
+ for loan in loans:
+ self._draw(loan)
+ report = self._report(date(2025, 1, 15))
+ self.assertEqual(report.line_ids.loan_id, loans)
+ action = report.action_view()
+ self.assertEqual(action['res_model'], 'account.loan.report.line')
+ self.assertEqual(self.env['account.loan.report.line'].search(action['domain']), report.line_ids)
+
+ def test_loans_analysis(self):
+ """ The schedule year by year: the principal of the running loans, not of the drafts. """
+ loan = self._create_loan(payment_count=24)
+ self._create_loan(confirm=False, principal=55555.0)
+ action = self.env.ref('om_account_loan.action_account_loan_analysis')
+ groups = self.env['account.loan.line']._read_group(
+ ast.literal_eval(action.domain), ['date:year'], ['principal:sum', 'interest:sum'])
+ self.assertEqual(len(groups), 3, "2025, 2026 and 2027")
+ self.assertAlmostEqual(sum(principal for _year, principal, _interest in groups), loan.principal, places=2)
+ self.assertAlmostEqual(sum(interest for _year, _principal, interest in groups), loan.interest_total, places=2)
+
+ def test_pdf_reports(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ self._pay(self._lines(loan)[0])
+ html = self.env['ir.actions.report']._render_qweb_html('om_account_loan.report_loan', loan.ids)[0]
+ self.assertIn(loan.name, str(html))
+ self.assertIn('8,606.64', str(html))
+ report = self._report(date(2025, 2, 15), loan)
+ html = self.env['ir.actions.report']._render_qweb_html(
+ 'om_account_loan.report_loan_position', report.ids)[0]
+ self.assertIn('91,893.36', str(html))
+
+
+@tagged('post_install', '-at_install')
+class TestLoanSecurity(LoanCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ company = cls.env.company
+ cls.manager = new_test_user(
+ cls.env, login='loan_manager', groups='account.group_account_manager,account.group_account_user',
+ company_id=company.id)
+ cls.accountant = new_test_user(
+ cls.env, login='loan_accountant', groups='account.group_account_user', company_id=company.id)
+ cls.invoicing = new_test_user(
+ cls.env, login='loan_invoicing', groups='account.group_account_invoice', company_id=company.id)
+ cls.auditor = new_test_user(
+ cls.env, login='loan_auditor', groups='account.group_account_readonly', company_id=company.id)
+
+ def test_manager_runs_loans(self):
+ loan = self._create_loan(confirm=False).with_user(self.manager)
+ loan.action_confirm()
+ self.env['account.loan.drawdown'].with_user(self.manager).create(
+ {'loan_id': loan.id, 'date': loan.date_start}).action_confirm()
+ self.env['account.loan.pay'].with_user(self.manager).create(
+ {'line_id': loan.line_ids.sorted('sequence')[0].id}).action_confirm()
+ self.assertEqual(loan.line_ids.sorted('sequence')[0].state, 'posted')
+
+ def test_accountant_runs_loans(self):
+ """ The accountant runs the loans: creates, confirms, draws, pays, repays, reclassifies; the
+ administrator alone deletes them. """
+ loan = self.env['account.loan'].with_user(self.accountant).create({
+ 'partner_id': self.lender.id, 'principal': 100000.0, 'date_start': date(2025, 1, 1),
+ 'date_first_payment': date(2025, 2, 1), 'payment_count': 12, 'rate': 6.0})
+ loan.action_confirm()
+ self.env['account.loan.drawdown'].with_user(self.accountant).create(
+ {'loan_id': loan.id, 'date': loan.date_start}).action_confirm()
+ line = loan.line_ids.sorted('sequence')[0]
+ self.env['account.loan.pay'].with_user(self.accountant).create({'line_id': line.id}).action_confirm()
+ self.assertEqual(line.state, 'posted')
+ self.env['account.loan.repay'].with_user(self.accountant).create(
+ {'loan_id': loan.id, 'date': date(2025, 2, 15), 'amount': 1000.0}).action_confirm()
+ self.env['account.loan.reclassify'].with_user(self.accountant).create(
+ {'date': date(2025, 2, 28), 'loan_ids': [Command.set(loan.ids)]}).action_confirm()
+ report = self.env['account.loan.report'].with_user(self.accountant).create({'date': date(2025, 2, 28)})
+ report._compute_lines()
+ self.assertEqual(report.line_ids.loan_id, loan)
+ draft = self.env['account.loan'].with_user(self.accountant).create(
+ {'partner_id': self.lender.id, 'principal': 1000.0})
+ with self.assertRaises(AccessError):
+ draft.unlink()
+ draft.with_user(self.manager).unlink()
+
+ def test_auditor_reads(self):
+ loan = self._create_loan()
+ self._draw(loan)
+ loan = loan.with_user(self.auditor)
+ self.assertEqual(loan.outstanding_amount, 100000.0)
+ self.assertTrue(loan.line_ids.mapped('amount'))
+ self.assertTrue(loan.move_ids)
+ with self.assertRaises(AccessError):
+ loan.lender_ref = 'X'
+ with self.assertRaises(AccessError):
+ self.env['account.loan.drawdown'].with_user(self.auditor).create({'loan_id': loan.id})
+
+ def test_invoicing_user_no_access(self):
+ loan = self._create_loan()
+ move = self._draw(loan).move_id
+ with self.assertRaises(AccessError):
+ loan.with_user(self.invoicing).read(['name'])
+ with self.assertRaises(AccessError):
+ self.env['account.loan.line'].with_user(self.invoicing).search([])
+ # the entries stay readable, without their loan
+ self.assertEqual(move.with_user(self.invoicing).read(['name'])[0]['name'], move.name)
+
+ def test_multi_company(self):
+ company_2 = self.setup_other_company(name='Loan Company 2')['company']
+ Account = self.env['account.account'].with_company(company_2)
+ accounts = {
+ fname: Account.create({'code': code, 'name': fname, 'account_type': account_type})
+ for fname, code, account_type in (
+ ('account_long_term_id', '171901', 'liability_non_current'),
+ ('account_short_term_id', '161901', 'liability_current'),
+ ('account_interest_id', '661901', 'expense'))
+ }
+ journal = self.env['account.journal'].with_company(company_2)
+ loan_2 = self.env['account.loan'].with_company(company_2).create({
+ 'partner_id': self.lender.id,
+ 'principal': 5000.0,
+ 'company_id': company_2.id,
+ 'date_start': date(2025, 1, 1),
+ **{fname: account.id for fname, account in accounts.items()},
+ 'journal_id': journal.search([('company_id', '=', company_2.id), ('type', '=', 'general')], limit=1).id,
+ 'bank_journal_id': journal.search([('company_id', '=', company_2.id), ('type', '=', 'bank')], limit=1).id,
+ })
+ loan_2.action_confirm()
+ loan_1 = self._create_loan()
+ self.assertEqual(
+ self.env['account.loan'].with_user(self.manager).search([('id', 'in', (loan_1 | loan_2).ids)]), loan_1)
+ self.assertFalse(
+ self.env['account.loan.line'].with_user(self.manager).search([('loan_id', '=', loan_2.id)]))
+ with self.assertRaises(AccessError):
+ loan_2.with_user(self.manager).read(['name'])
+ self.manager.company_ids |= company_2
+ both = self.env['account.loan'].with_user(self.manager).with_context(
+ allowed_company_ids=(self.env.company | company_2).ids)
+ self.assertEqual(both.search([('id', 'in', (loan_1 | loan_2).ids)]), loan_1 | loan_2)
diff --git a/om_account_loan/tests/test_schedule.py b/om_account_loan/tests/test_schedule.py
new file mode 100644
index 000000000..786e6fb29
--- /dev/null
+++ b/om_account_loan/tests/test_schedule.py
@@ -0,0 +1,140 @@
+from datetime import date
+
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
+
+from odoo.addons.om_account_loan.models.account_loan import annuity, annuity_count
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanSchedule(LoanCommon):
+
+ def test_annuity_formula(self):
+ self.assertAlmostEqual(annuity(100000.0, 0.005, 12), 8606.64, places=2)
+ self.assertAlmostEqual(annuity(1200.0, 0.0, 12), 100.0)
+ self.assertEqual(annuity_count(100000.0, 0.005, 8606.65), 12)
+ self.assertEqual(annuity_count(1200.0, 0.0, 100.0), 12)
+ self.assertIsNone(annuity_count(100000.0, 0.005, 500.0), "the instalment only pays the interest")
+
+ def test_reference_from_sequence(self):
+ loan = self._create_loan(confirm=False)
+ self.assertTrue(loan.name.startswith('LOAN/2025/'))
+
+ def test_annuity_schedule(self):
+ """ 100,000 at 6% over 12 monthly payments: 8,606.64 a month, the residue on the last one. """
+ loan = self._create_loan()
+ lines = self._lines(loan)
+ self.assertEqual(len(lines), 12)
+ self.assertEqual(lines[0].date, date(2025, 2, 1))
+ self.assertEqual(lines[-1].date, date(2026, 1, 1))
+ self.assertEqual(lines[0].opening_balance, 100000.0)
+ self.assertEqual(lines[0].interest, 500.0)
+ self.assertEqual(lines[0].principal, 8106.64)
+ self.assertEqual(lines[1].interest, 459.47)
+ self.assertEqual(set(lines[:-1].mapped('amount')), {8606.64})
+ self.assertEqual(lines[-1].principal, 8563.87)
+ self.assertEqual(lines[-1].interest, 42.82)
+ self.assertEqual(lines[-1].amount, 8606.69, "the rounding residue goes on the last line")
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+ self.assertAlmostEqual(sum(lines.mapped('interest')), 3279.73, places=2)
+ self.assertEqual(lines[-1].closing_balance, 0.0)
+ for previous, line in zip(lines, lines[1:], strict=False):
+ self.assertEqual(line.opening_balance, previous.closing_balance)
+ self.assertEqual(loan.next_date, date(2025, 2, 1))
+ self.assertEqual(loan.date_end, date(2026, 1, 1))
+
+ def test_fixed_principal(self):
+ loan = self._create_loan(principal=120000.0, schedule_method='fixed_principal')
+ lines = self._lines(loan)
+ self.assertEqual(set(lines.mapped('principal')), {10000.0})
+ self.assertEqual(lines[:3].mapped('interest'), [600.0, 550.0, 500.0])
+ self.assertEqual(lines[-1].interest, 50.0)
+
+ def test_rounding_residue(self):
+ loan = self._create_loan(schedule_method='fixed_principal', payment_count=3, rate=0.0)
+ self.assertEqual(self._lines(loan).mapped('principal'), [33333.33, 33333.33, 33333.34])
+
+ def test_calendar_months(self):
+ """ A first payment on the last day of a month keeps the payments on the last day of the months. """
+ loan = self._create_loan(date_start=date(2023, 12, 31), date_first_payment=date(2024, 1, 31))
+ self.assertEqual(self._lines(loan)[:4].mapped('date'), [
+ date(2024, 1, 31), date(2024, 2, 29), date(2024, 3, 31), date(2024, 4, 30)])
+ loan = self._create_loan(date_start=date(2024, 12, 31), date_first_payment=date(2025, 1, 31))
+ self.assertEqual(self._lines(loan)[1].date, date(2025, 2, 28))
+ loan = self._create_loan(date_start=date(2024, 12, 15), date_first_payment=date(2025, 1, 15),
+ payment_frequency='quarterly', payment_count=4)
+ self.assertEqual(self._lines(loan).mapped('date'), [
+ date(2025, 1, 15), date(2025, 4, 15), date(2025, 7, 15), date(2025, 10, 15)])
+
+ def test_first_payment_default(self):
+ loan = self.env['account.loan'].create({
+ 'partner_id': self.lender.id, 'principal': 1000.0, 'date_start': date(2025, 1, 1),
+ 'payment_frequency': 'semiannual'})
+ self.assertEqual(loan.date_first_payment, date(2025, 7, 1))
+ loan.date_start = date(2025, 1, 31)
+ self.assertEqual(loan.date_first_payment, date(2025, 7, 31))
+ with self.assertRaises(ValidationError):
+ loan.date_first_payment = date(2025, 1, 1)
+
+ def test_broken_first_period(self):
+ """ A first period shorter than a period counts its days. """
+ loan = self._create_loan(date_start=date(2025, 1, 16), date_first_payment=date(2025, 1, 31))
+ # 15 days of the 31 days from 31 December to 31 January
+ self.assertEqual(self._lines(loan)[0].interest, round(100000 * 0.06 / 12 * 15 / 31, 2))
+
+ def test_actual_365(self):
+ loan = self._create_loan(rate=5.0, interest_method='actual_365', date_first_payment=date(2025, 1, 31),
+ schedule_method='fixed_principal', payment_count=10)
+ lines = self._lines(loan)
+ self.assertEqual(lines[0].interest, 410.96, "100,000 x 5% x 30 / 365")
+ self.assertEqual(lines[1].interest, round(90000 * 0.05 * 28 / 365, 2))
+ self.assertEqual(lines[2].interest, round(80000 * 0.05 * 31 / 365, 2))
+
+ def test_edit_unpaid_line(self):
+ """ A principal changed by hand moves the difference to the last line; a recompute discards it. """
+ loan = self._create_loan()
+ lines = self._lines(loan)
+ lines[1].principal = 10000.0
+ self.assertEqual(lines[2].opening_balance, lines[1].closing_balance)
+ self.assertAlmostEqual(sum(lines.mapped('principal')), 100000.0, places=2)
+ self.assertAlmostEqual(lines[-1].principal, 8563.87 - (10000.0 - 8147.17), places=2)
+ lines[3].date = date(2025, 5, 5)
+ self.assertEqual(lines[3].date, date(2025, 5, 5))
+ with self.assertRaises(UserError):
+ lines[-2].principal = 100000.0
+ loan.action_compute_schedule()
+ self.assertEqual(lines[1].principal, 8147.17)
+ self.assertEqual(lines[3].date, date(2025, 5, 1))
+
+ def test_terms_locked_after_entries(self):
+ loan = self._create_loan(confirm=False)
+ loan.principal = 90000.0
+ loan.action_confirm()
+ # running: the terms are fixed, with or without entries
+ with self.assertRaises(UserError):
+ loan.principal = 85000.0
+ self._draw(loan)
+ with self.assertRaises(UserError):
+ loan.principal = 80000.0
+ with self.assertRaises(UserError):
+ loan.unlink()
+
+ def test_configuration_required(self):
+ loan = self._create_loan(confirm=False, account_interest_id=False)
+ with self.assertRaises(UserError):
+ loan.action_confirm()
+ with self.assertRaises(ValidationError):
+ loan.account_short_term_id = self.long_term_account
+ with self.assertRaises(ValidationError):
+ loan.account_long_term_id = self.company_data['default_account_payable']
+
+ def test_cancel_and_reset(self):
+ loan = self._create_loan()
+ loan.action_cancel()
+ self.assertEqual(loan.state, 'cancel')
+ loan.action_draft()
+ self.assertEqual(loan.state, 'draft')
+ self.assertFalse(loan.line_ids)
+ loan.unlink()
diff --git a/om_account_loan/tests/test_tour.py b/om_account_loan/tests/test_tour.py
new file mode 100644
index 000000000..ec4b8dec5
--- /dev/null
+++ b/om_account_loan/tests/test_tour.py
@@ -0,0 +1,42 @@
+from odoo.tests import HttpCase, tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanTour(LoanCommon, HttpCase):
+
+ def test_loan_tour(self):
+ """ Create a loan, confirm it, draw it down, compute the schedule and pay the first instalment. """
+ self.env.ref('base.user_admin').write({
+ 'company_id': self.env.company.id,
+ 'company_ids': [(4, self.env.company.id)],
+ })
+ self.lender.name = 'Tour Lending Bank'
+ self.start_tour('/odoo/loans', 'om_account_loan_tour', login='admin')
+ loan = self.env['account.loan'].search([('partner_id', '=', self.lender.id)])
+ self.assertEqual(loan.state, 'running')
+ self.assertEqual(loan.principal, 24000.0)
+ self.assertEqual(loan.drawn_amount, 24000.0)
+ self.assertEqual(len(loan.line_ids), 24)
+ first = loan.line_ids.sorted('sequence')[0]
+ self.assertEqual(first.state, 'posted')
+ self.assertEqual(first.move_id.journal_id, self.bank_journal)
+ self.assertEqual(loan.outstanding_amount, 24000.0 - first.principal)
+
+ def test_lease_tour(self):
+ """ Create a lease, confirm it and bill its first payment. """
+ self.env.ref('base.user_admin').write({
+ 'company_id': self.env.company.id,
+ 'company_ids': [(4, self.env.company.id)],
+ })
+ self.lessor.name = 'Tour Lessor Properties'
+ self.start_tour('/odoo/leases', 'om_account_loan_lease_tour', login='admin')
+ lease = self.env['account.loan'].search([('partner_id', '=', self.lessor.id)])
+ self.assertEqual((lease.loan_type, lease.state), ('lease', 'running'))
+ # the present value of the payments (it depends on the days of the first period, from today)
+ self.assertAlmostEqual(lease.principal, 11618.93, delta=60.0)
+ self.assertEqual(lease.operation_ids.amount, lease.principal)
+ first = lease.line_ids.sorted('sequence')[0]
+ self.assertEqual(first.move_id.move_type, 'in_invoice')
+ self.assertEqual(first.move_id.state, 'posted')
diff --git a/om_account_loan/tests/test_tracking.py b/om_account_loan/tests/test_tracking.py
new file mode 100644
index 000000000..cfecb7c54
--- /dev/null
+++ b/om_account_loan/tests/test_tracking.py
@@ -0,0 +1,81 @@
+from datetime import date
+from unittest.mock import patch
+
+from odoo import Command
+from odoo.tests import new_test_user, tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestLoanTracking(LoanCommon):
+
+ def _bodies(self, loan):
+ return ' | '.join(loan.message_ids.mapped(lambda message: str(message.body)))
+
+ def test_chatter(self):
+ """ The payments, the reclassifications, the rate changes and the lines changed by hand are in the
+ chatter of the loan. """
+ loan = self._create_loan(rate_type='floating', payment_count=24)
+ self._draw(loan)
+ lines = self._lines(loan)
+ self._pay(lines[0])
+ self.assertIn('Instalment 1 paid on', self._bodies(loan))
+ loan._reclassify(date(2025, 2, 28))
+ self.assertIn('moved to the short-term account', self._bodies(loan))
+ loan.rate_line_ids = [Command.create({'date': date(2025, 3, 2), 'rate': 7.5})]
+ self.assertIn('Rate change: 7.5 % from', self._bodies(loan))
+ lines[2].principal = 100.0
+ self.assertIn('Instalment 3 changed: Principal', self._bodies(loan))
+
+ def test_lease_bill_in_chatter(self):
+ lease = self._create_lease()
+ bill = self._lines(lease)[0]._create_bill()
+ self.assertIn(f'Bill {bill.display_name} of the lease payment 1 created', self._bodies(lease))
+
+ def test_invoicing_user_reverses_reclassification(self):
+ """ The reclassification is not a schedule entry: an invoicing user may reverse it, and the reversal
+ stays in the balances of the loan. """
+ invoicing = new_test_user(
+ self.env, login='loan_track_invoicing', groups='account.group_account_invoice',
+ company_id=self.env.company.id)
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ move = loan._reclassify(date(2025, 1, 31))
+ reversal = move.with_user(invoicing)._reverse_moves([{'date': move.date}], cancel=True)
+ self.assertEqual(reversal.sudo().loan_id, loan)
+ self.assertEqual(loan._account_balance_at(self.short_term_account, date(2025, 1, 31)), 0.0)
+
+ def test_report_totals_in_company_currency(self):
+ """ The loans in several currencies add up in the company currency only. """
+ eur = self.setup_other_currency('EUR', rates=[('2025-01-01', 2.0)])
+ loan = self._create_loan(payment_count=24)
+ self._draw(loan)
+ euro_loan = self._create_loan(currency_id=eur.id, payment_count=24)
+ self._draw(euro_loan)
+ report = self.env['account.loan.report'].create({'date': date(2025, 1, 31)})
+ report._compute_lines()
+ Line = self.env['account.loan.report.line']
+ self.assertFalse(Line._fields['outstanding'].aggregator)
+ euro_line = report.line_ids.filtered(lambda line: line.loan_id == euro_loan)
+ self.assertEqual(euro_line.outstanding_company, 50000.0)
+ self.assertEqual(euro_line.due_within_company + euro_line.due_after_company, 50000.0)
+ self.assertAlmostEqual(euro_line.due_within_company, euro_line.due_within / 2, delta=0.01)
+ self.assertEqual(sum(report.line_ids.mapped('outstanding_company')), 150000.0)
+
+ def test_bill_left_draft_for_its_document_number(self):
+ """ A journal using documents (Latin America) asks for the number of the lessor's document: the bill is
+ left in draft, the lease says why. """
+ lease = self._create_lease()
+ with patch.object(type(self.env['account.move']), '_loan_needs_document_number', return_value=True):
+ bill = self._lines(lease)[0]._create_bill()
+ self.assertEqual(bill.state, 'draft')
+ self.assertIn('asks for the document number', self._bodies(lease))
+ bill.action_post()
+ self.assertEqual(bill.state, 'posted')
+
+ def test_lease_tax_withheld_on_payment(self):
+ """ A tax withheld on the payments of the bills is not withheld by the schedule of the lease. """
+ with patch.object(type(self.env['account.loan']), '_withholding_on_bill_payment', return_value=True):
+ lease = self._create_lease(withholding_tax_id=self.withholding_tax.id)
+ self.assertFalse(any(self._lines(lease).mapped('withholding')))
diff --git a/om_account_loan/tests/test_withholding.py b/om_account_loan/tests/test_withholding.py
new file mode 100644
index 000000000..ee1cbe7d0
--- /dev/null
+++ b/om_account_loan/tests/test_withholding.py
@@ -0,0 +1,101 @@
+from datetime import date
+
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
+
+from .common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestWithholding(LoanCommon):
+
+ def test_withholding_on_schedule(self):
+ """ The schedule shows the tax withheld on the interest of each line and what the lender receives. """
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ lines = self._lines(loan)
+ self.assertEqual((lines[0].interest, lines[0].withholding, lines[0].net_amount), (500.0, 5.0, 8601.64))
+ self.assertEqual(loan.withholding_total, round(sum(lines.mapped('withholding')), 2))
+ loan.withholding_tax_id = False
+ self.assertEqual(lines[0].withholding, 0.0)
+
+ def test_payment_withholds_tax(self):
+ """ Dr loan + Dr interest / Cr withholding tax (on its account, as a tax line) + Cr outstanding payments
+ for the instalment less the tax. """
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ move = self._pay(line)
+ self.assertEqual(self._entry(move), {
+ self.long_term_account.code: (8106.64, 0.0, 8106.64),
+ self.interest_account.code: (500.0, 0.0, 500.0),
+ self.withholding_account.code: (0.0, 5.0, -5.0),
+ self.outstanding_payments.code: (0.0, 8601.64, -8601.64),
+ })
+ tax_line = move.line_ids.filtered(lambda aml: aml.account_id == self.withholding_account)
+ self.assertEqual(tax_line.tax_line_id, self.withholding_tax)
+ self.assertEqual(tax_line.tax_base_amount, 500.0)
+ self.assertEqual(line.withholding, 5.0)
+ # what was withheld stays on the paid line
+ loan.withholding_tax_id = False
+ self.assertEqual(line.withholding, 5.0)
+
+ def test_withholding_changed_in_wizard(self):
+ """ The tax withheld by the bank, if it differs, is set when paying. """
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ wizard = self.env['account.loan.pay'].create({'line_id': line.id})
+ self.assertEqual((wizard.withholding, wizard.net_amount), (5.0, 8601.64))
+ wizard.interest = 510.0
+ self.assertEqual(wizard.withholding, 5.1)
+ wizard.withholding = 5.0
+ wizard.action_confirm()
+ self.assertEqual(self._entry(line.move_id)[self.withholding_account.code], (0.0, 5.0, -5.0))
+ self.assertEqual(line.withholding, 5.0)
+ wizard = self.env['account.loan.pay'].create({'line_id': self._lines(loan)[1].id})
+ wizard.withholding = 1000.0
+ with self.assertRaises(UserError):
+ wizard.action_confirm()
+
+ def test_cancel_payment_with_withholding(self):
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ self._draw(loan)
+ line = self._lines(loan)[0]
+ move = self._pay(line)
+ line.action_cancel_payment()
+ self.assertEqual(move.reversal_move_ids.line_ids.filtered(
+ lambda aml: aml.account_id == self.withholding_account).debit, 5.0)
+ self.assertEqual(line.withholding, 5.0)
+
+ def test_early_repayment_withholds_tax(self):
+ """ The interest of an early repayment has its tax withheld too. """
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ self._draw(loan)
+ operation = self._repay(loan, date(2025, 1, 16), 100000.0, interest=250.0)
+ self.assertEqual(operation.withholding, 2.5)
+ entry = self._entry(operation.move_id)
+ self.assertEqual(entry[self.withholding_account.code], (0.0, 2.5, -2.5))
+ self.assertEqual(entry[self.outstanding_payments.code], (0.0, 100247.5, -100247.5))
+ report = self.env['account.loan.report'].create({'date': date(2025, 1, 31)})
+ report._compute_lines()
+ self.assertEqual(report.line_ids.filtered(lambda line: line.loan_id == loan).withheld, 2.5)
+
+ def test_withholding_tax_negative(self):
+ """ A withholding tax reduces the payment: its amount is negative. """
+ positive = self._create_withholding_tax(1.0, name='Positive tax')
+ with self.assertRaises(ValidationError):
+ self._create_loan(confirm=False, withholding_tax_id=positive.id)
+
+ def test_withholding_without_account(self):
+ tax = self._create_withholding_tax(-1.0, name='WHT without account')
+ tax.invoice_repartition_line_ids.filtered(lambda rep: rep.repartition_type == 'tax').account_id = False
+ loan = self._create_loan(withholding_tax_id=tax.id)
+ self._draw(loan)
+ with self.assertRaises(UserError):
+ self._pay(self._lines(loan)[0])
+
+ def test_pdf_shows_withholding(self):
+ loan = self._create_loan(withholding_tax_id=self.withholding_tax.id)
+ html = self.env['ir.actions.report']._render_qweb_html('om_account_loan.report_loan', loan.ids)[0]
+ self.assertIn(b'Withholding Tax', html)
+ self.assertIn(b'Net Payment', html)
diff --git a/om_account_loan/views/account_loan_views.xml b/om_account_loan/views/account_loan_views.xml
new file mode 100644
index 000000000..1479d51f6
--- /dev/null
+++ b/om_account_loan/views/account_loan_views.xml
@@ -0,0 +1,425 @@
+
+
+
+
+ account.loan.form
+ account.loan
+
+
+
+
+
+
+ account.loan.list
+ account.loan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account.loan.list.lease
+ account.loan
+
+ primary
+
+
+ Leases
+
+
+ Lessor
+
+
+ Lessor Reference
+
+
+
+
+
+ Lease Liability
+
+
+ Next Payment Date
+
+
+ Next Lease Payment
+
+
+
+
+
+ account.loan.search
+ account.loan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loans
+ account.loan
+ loans
+ list,form
+
+ [('loan_type', '=', 'loan')]
+ {'search_default_running': 1, 'search_default_draft': 1, 'loan_default_type': 'loan'}
+
+
Record a loan
+
Enter the lender, the principal, the rate and the instalments: the schedule is computed when the loan
+ is confirmed. Then draw it down, pay the instalments from the schedule and reclassify the current
+ part at each period end.
Enter the lessor, the lease payment, the number of payments and the rate: on confirmation the lease
+ liability is recognized at the present value of the payments, against the right-of-use asset.
+ Each payment is then billed by the lessor on its due date.
+
+
+
+
+
+
+
diff --git a/om_account_loan/wizard/__init__.py b/om_account_loan/wizard/__init__.py
new file mode 100644
index 000000000..22fc1e460
--- /dev/null
+++ b/om_account_loan/wizard/__init__.py
@@ -0,0 +1,5 @@
+from . import account_loan_drawdown
+from . import account_loan_pay
+from . import account_loan_repay
+from . import account_loan_reclassify
+from . import account_loan_import
diff --git a/om_account_loan/wizard/account_loan_drawdown.py b/om_account_loan/wizard/account_loan_drawdown.py
new file mode 100644
index 000000000..d13dd44f6
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_drawdown.py
@@ -0,0 +1,78 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+
+
+class AccountLoanDrawdown(models.TransientModel):
+ _name = 'account.loan.drawdown'
+ _description = 'Loan Drawdown'
+ _check_company_auto = True
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True)
+ company_id = fields.Many2one(related='loan_id.company_id')
+ currency_id = fields.Many2one(related='loan_id.currency_id')
+ undrawn_amount = fields.Monetary(related='loan_id.undrawn_amount')
+ date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
+ amount = fields.Monetary(
+ string='Amount', compute='_compute_amount', store=True, readonly=False, precompute=True, required=True)
+ journal_id = fields.Many2one(
+ 'account.journal', string='Bank Journal', required=True, check_company=True,
+ compute='_compute_journal_id', store=True, readonly=False, precompute=True,
+ domain="[('type', 'in', ('bank', 'cash'))]")
+
+ @api.depends('loan_id')
+ def _compute_amount(self):
+ for wizard in self:
+ wizard.amount = wizard.loan_id.undrawn_amount
+
+ @api.depends('loan_id')
+ def _compute_journal_id(self):
+ for wizard in self:
+ wizard.journal_id = wizard.loan_id.bank_journal_id
+
+ def _check_drawdown(self):
+ loan = self.loan_id
+ currency = loan.currency_id
+ if loan.state != 'running':
+ raise UserError(self.env._('Confirm the loan %s first.', loan.display_name))
+ if loan.loan_type == 'lease':
+ raise UserError(self.env._('A lease is not drawn down: its liability is recognized on confirmation.'))
+ if currency.compare_amounts(self.amount, 0.0) <= 0:
+ raise UserError(self.env._('The amount of a drawdown must be positive.'))
+ if currency.compare_amounts(self.amount, loan.undrawn_amount) > 0:
+ raise UserError(self.env._(
+ 'The loan %(loan)s has %(amount)s left to draw.',
+ loan=loan.display_name, amount=currency.format(loan.undrawn_amount)))
+ if self.date < loan.date_start:
+ raise UserError(self.env._('A drawdown cannot be dated before the start of the loan.'))
+ last = loan.line_ids.sorted('sequence')[-1:]
+ if last and self.date >= last.date:
+ raise UserError(self.env._('A drawdown must be dated before the last instalment of the loan.'))
+ if self.journal_id.currency_id and self.journal_id.currency_id != currency:
+ raise UserError(self.env._('The bank journal is not in the currency of the loan.'))
+ loan._check_paid_up_to(self.date)
+
+ def action_confirm(self):
+ """ Post Dr bank (outstanding receipts) / Cr long-term loan, then compute the unpaid lines again from
+ the principal drawn. """
+ self.ensure_one()
+ self._check_drawdown()
+ loan = self.loan_id
+ label = self.env._('%(loan)s - Drawdown', loan=loan.name)
+ move = loan._create_move(self.journal_id, self.date, [
+ (loan.account_long_term_id, -self.amount, label),
+ (loan._bank_account(self.journal_id, 'inbound'), 0.0, label),
+ ], 'drawdown', label)
+ self.env['account.loan.operation'].create({
+ 'loan_id': loan.id,
+ 'operation_type': 'drawdown',
+ 'date': self.date,
+ 'amount': self.amount,
+ 'journal_id': self.journal_id.id,
+ 'move_id': move.id,
+ })
+ loan._generate_schedule()
+ loan.message_post(body=self.env._(
+ 'Drawdown of %(amount)s on %(date)s.', amount=loan.currency_id.format(self.amount),
+ date=format_date(self.env, self.date)))
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/om_account_loan/wizard/account_loan_drawdown_views.xml b/om_account_loan/wizard/account_loan_drawdown_views.xml
new file mode 100644
index 000000000..1d77e3984
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_drawdown_views.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ account.loan.drawdown.form
+ account.loan.drawdown
+
+
+
+
+
+
diff --git a/om_account_loan/wizard/account_loan_import.py b/om_account_loan/wizard/account_loan_import.py
new file mode 100644
index 000000000..58ffdefc5
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_import.py
@@ -0,0 +1,214 @@
+import csv
+import io
+import re
+from datetime import date, datetime
+from itertools import pairwise
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.fields import Command
+from odoo.tools.misc import format_date, get_lang
+
+from odoo.addons.om_account_loan.models.loan_context import NO_REGENERATE, SCHEDULE_UPDATE, internal_context
+
+DATE_FORMATS = ('%Y-%m-%d', '%d/%m/%Y', '%d.%m.%Y', '%d-%m-%Y')
+MAX_ERRORS = 10
+
+
+def parse_amount(value):
+ """ The number in a cell: 1234.56, 1,234.56, 1.234,56, 1234,56 or 1 234,56.
+
+ :raise ValueError: when it is not a number
+ """
+ if isinstance(value, (int, float)):
+ return float(value)
+ text = re.sub(r'[\s\u00a0\u202f]', '', str(value or ''))
+ if not text:
+ return 0.0
+ if ',' in text and '.' in text:
+ if text.rfind(',') > text.rfind('.'):
+ text = text.replace('.', '').replace(',', '.')
+ else:
+ text = text.replace(',', '')
+ elif ',' in text:
+ # one comma followed by one or two digits is a decimal comma, any other is a thousands separator
+ text = text.replace(',', '.') if re.fullmatch(r'-?\d*,\d{1,2}', text) else text.replace(',', '')
+ return float(text)
+
+
+class AccountLoanImport(models.TransientModel):
+ _name = 'account.loan.import'
+ _description = 'Import a Loan Schedule'
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True)
+ company_id = fields.Many2one(related='loan_id.company_id')
+ currency_id = fields.Many2one(related='loan_id.currency_id')
+ file = fields.Binary(string='File', required=True, help="A CSV or XLSX file, one row per instalment.")
+ filename = fields.Char(string='File Name')
+ columns = fields.Selection(
+ [('principal', 'Date, Principal, Interest'), ('instalment', 'Date, Instalment, Interest')],
+ string='Columns', required=True, default='principal',
+ help="The three first columns of the file. A first row of titles is skipped.")
+ principal_to_import = fields.Monetary(
+ string='Principal to Schedule', compute='_compute_principal_to_import',
+ help="The principal the imported lines must repay: the principal of the loan less the paid instalments "
+ "and the early repayments.")
+
+ @api.depends('loan_id')
+ def _compute_principal_to_import(self):
+ for wizard in self:
+ wizard.principal_to_import = wizard._principal_to_import() if wizard.loan_id else 0.0
+
+ def _principal_to_import(self):
+ loan = self.loan_id
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted')
+ repaid = loan.operation_ids.filtered(lambda op: op.state == 'posted' and op.operation_type == 'repayment')
+ return loan.currency_id.round(loan.principal - sum(paid.mapped('principal')) - sum(repaid.mapped('amount')))
+
+ # ------------------------------------------------------------
+ # Reading the file
+ # ------------------------------------------------------------
+
+ def _read_rows(self):
+ """ The rows of the file, as lists of cells (strings, numbers or dates). """
+ self.ensure_one()
+ content = self.file.content
+ if not content:
+ raise UserError(self.env._('The file is empty.'))
+ if (self.filename or '').lower().endswith('.xlsx') or content[:2] == b'PK':
+ return self._read_xlsx(content)
+ return self._read_csv(content)
+
+ def _read_xlsx(self, content):
+ import openpyxl # only needed for this format
+
+ try:
+ book = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
+ except Exception as error: # any unreadable file
+ raise UserError(self.env._('The file is not a readable XLSX file: %s', error)) from error
+ sheet = book.worksheets[0]
+ return [['' if cell is None else cell for cell in row] for row in sheet.iter_rows(values_only=True)]
+
+ def _read_csv(self, content):
+ try:
+ text = content.decode('utf-8-sig')
+ except UnicodeDecodeError:
+ text = content.decode('latin-1')
+ # a semicolon or a tab in the first line separates the columns, the commas being decimal ones
+ first = next((line for line in text.splitlines() if line.strip()), '')
+ delimiter = next((char for char in (';', '\t') if char in first), ',')
+ return list(csv.reader(io.StringIO(text), delimiter=delimiter))
+
+ def _parse_date(self, value):
+ if isinstance(value, datetime):
+ return value.date()
+ if isinstance(value, date):
+ return value
+ text = str(value or '').strip()
+ for date_format in (*DATE_FORMATS, get_lang(self.env).date_format):
+ try:
+ return datetime.strptime(text, date_format).date()
+ except ValueError:
+ continue
+ raise ValueError(text)
+
+ def _parse_rows(self, rows):
+ """ The instalments of the file, checked one by one.
+
+ :return: list of dicts with date, principal, interest
+ :raise UserError: listing the rows in error
+ """
+ currency = self.currency_id
+ values, errors = [], []
+ for number, row in enumerate(rows, start=1):
+ cells = list(row) + [''] * 3
+ if not any(str(cell).strip() for cell in cells[:3]):
+ continue
+ try:
+ day = self._parse_date(cells[0])
+ except ValueError:
+ if number == 1:
+ continue # the titles
+ errors.append(self.env._('Row %(row)s: %(value)s is not a date.', row=number, value=cells[0]))
+ continue
+ try:
+ first, interest = parse_amount(cells[1]), parse_amount(cells[2])
+ except ValueError:
+ errors.append(self.env._('Row %s: the amounts are not numbers.', number))
+ continue
+ interest = currency.round(interest)
+ principal = currency.round(first - interest if self.columns == 'instalment' else first)
+ if currency.compare_amounts(interest, 0.0) < 0:
+ errors.append(self.env._('Row %s: the interest is negative.', number))
+ if currency.compare_amounts(principal, 0.0) < 0:
+ errors.append(self.env._(
+ 'Row %s: the principal is negative (an instalment lower than its interest).', number))
+ values.append({'row': number, 'date': day, 'principal': principal, 'interest': interest})
+ if errors:
+ raise UserError('\n'.join(errors[:MAX_ERRORS]))
+ return values
+
+ def _check_schedule(self, values):
+ """ Check the imported instalments against the loan: dates increasing and after the paid ones, the
+ principal adding up to what is left to repay. """
+ loan = self.loan_id
+ currency = self.currency_id
+ if not values:
+ raise UserError(self.env._('The file has no instalment.'))
+ errors = []
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted')
+ operations = loan.operation_ids.filtered(lambda op: op.state == 'posted')
+ after = max([loan.date_start, *paid.mapped('date'), *operations.mapped('date')])
+ if values[0]['date'] <= after:
+ errors.append(self.env._(
+ 'Row %(row)s: the first instalment must be due after %(date)s.', row=values[0]['row'],
+ date=format_date(self.env, after)))
+ for previous, vals in pairwise(values):
+ if vals['date'] <= previous['date']:
+ errors.append(self.env._(
+ 'Row %(row)s: the due dates must increase, %(date)s is not after %(previous)s.',
+ row=vals['row'], date=format_date(self.env, vals['date']),
+ previous=format_date(self.env, previous['date'])))
+ total = currency.round(sum(vals['principal'] for vals in values))
+ expected = self._principal_to_import()
+ if currency.compare_amounts(total, expected):
+ errors.append(self.env._(
+ 'The principal of the file adds up to %(total)s: the loan has %(expected)s to repay.',
+ total=currency.format(total), expected=currency.format(expected)))
+ if errors:
+ raise UserError('\n'.join(errors[:MAX_ERRORS]))
+
+ # ------------------------------------------------------------
+ # Import
+ # ------------------------------------------------------------
+
+ def action_import(self):
+ """ Replace the unpaid lines of the loan by the instalments of the file. """
+ self.ensure_one()
+ loan = self.loan_id
+ if loan.state not in ('draft', 'running') or loan.schedule_method != 'imported':
+ raise UserError(self.env._('Choose the Imported schedule method on a draft or running loan first.'))
+ values = self._parse_rows(self._read_rows())
+ self._check_schedule(values)
+ kept = loan.line_ids.filtered(lambda line: line.state == 'posted')
+ commands = [Command.unlink(line.id) for line in loan.line_ids - kept]
+ commands += [
+ Command.create({
+ 'sequence': number,
+ 'date': vals['date'],
+ 'principal': vals['principal'],
+ 'interest': vals['interest'],
+ 'rate': loan._rate_at(vals['date']),
+ })
+ for number, vals in enumerate(values, start=len(kept) + 1)
+ ]
+ loan_vals = {'payment_count': len(kept) + len(values)}
+ if not kept and not loan._has_entries():
+ loan_vals['date_first_payment'] = values[0]['date']
+ loan.with_context(**internal_context(NO_REGENERATE)).write(loan_vals)
+ loan.with_context(**internal_context(SCHEDULE_UPDATE)).line_ids = commands
+ loan._chain_balances()
+ loan.message_post(body=self.env._(
+ 'Schedule of %(count)s instalments imported from %(file)s.',
+ count=len(values), file=self.filename or self.env._('a file')))
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/om_account_loan/wizard/account_loan_import_views.xml b/om_account_loan/wizard/account_loan_import_views.xml
new file mode 100644
index 000000000..da200fe27
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_import_views.xml
@@ -0,0 +1,36 @@
+
+
+
+
+ account.loan.import.form
+ account.loan.import
+
+
+
+
+
+
diff --git a/om_account_loan/wizard/account_loan_pay.py b/om_account_loan/wizard/account_loan_pay.py
new file mode 100644
index 000000000..457053b59
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_pay.py
@@ -0,0 +1,84 @@
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+
+class AccountLoanPay(models.TransientModel):
+ _name = 'account.loan.pay'
+ _description = 'Pay a Loan Instalment'
+ _check_company_auto = True
+
+ line_id = fields.Many2one('account.loan.line', string='Instalment', required=True)
+ line_sequence = fields.Integer(related='line_id.sequence', string='Instalment Number')
+ line_date = fields.Date(related='line_id.date', string='Due Date')
+ loan_id = fields.Many2one(related='line_id.loan_id')
+ company_id = fields.Many2one(related='line_id.company_id')
+ currency_id = fields.Many2one(related='line_id.currency_id')
+ date = fields.Date(
+ string='Payment Date', required=True, compute='_compute_date', store=True, readonly=False, precompute=True)
+ journal_id = fields.Many2one(
+ 'account.journal', string='Bank Journal', required=True, check_company=True,
+ compute='_compute_journal_id', store=True, readonly=False, precompute=True,
+ domain="[('type', 'in', ('bank', 'cash'))]")
+ principal = fields.Monetary(
+ string='Principal', compute='_compute_principal', store=True, readonly=False, precompute=True)
+ interest = fields.Monetary(
+ string='Interest', compute='_compute_interest', store=True, readonly=False, precompute=True)
+ amount = fields.Monetary(string='Total', compute='_compute_amount')
+ withholding_tax_id = fields.Many2one(string='Withholding Tax Rule', related='line_id.loan_id.withholding_tax_id')
+ withholding = fields.Monetary(
+ string='Withholding Tax', compute='_compute_withholding', store=True, readonly=False, precompute=True,
+ help="The tax withheld on the interest, deducted from the payment to the lender.")
+ net_amount = fields.Monetary(string='Paid to the Lender', compute='_compute_amount')
+ principal_changed = fields.Boolean(compute='_compute_amount')
+
+ @api.depends('line_id')
+ def _compute_date(self):
+ for wizard in self:
+ wizard.date = wizard.line_id.date
+
+ @api.depends('line_id')
+ def _compute_journal_id(self):
+ for wizard in self:
+ wizard.journal_id = wizard.line_id.loan_id.bank_journal_id
+
+ @api.depends('line_id')
+ def _compute_principal(self):
+ for wizard in self:
+ wizard.principal = wizard.line_id.principal
+
+ @api.depends('line_id')
+ def _compute_interest(self):
+ for wizard in self:
+ wizard.interest = wizard.line_id.interest
+
+ @api.depends('line_id', 'interest')
+ def _compute_withholding(self):
+ for wizard in self:
+ loan = wizard.line_id.loan_id
+ wizard.withholding = loan._withholding_amount(wizard.interest) if loan else 0.0
+
+ @api.depends('principal', 'interest', 'withholding')
+ def _compute_amount(self):
+ for wizard in self:
+ wizard.amount = wizard.principal + wizard.interest
+ wizard.net_amount = wizard.amount - wizard.withholding
+ wizard.principal_changed = bool(wizard.line_id) and bool(
+ wizard.currency_id.compare_amounts(wizard.principal, wizard.line_id.principal))
+
+ def action_confirm(self):
+ """ Post Dr loan (principal) + Dr interest / Cr withholding tax + Cr bank (outstanding payments) for the
+ instalment; a principal or an interest changed here is written on the instalment first. """
+ self.ensure_one()
+ line = self.line_id
+ currency = line.currency_id
+ if currency.compare_amounts(self.principal, 0.0) < 0 or currency.compare_amounts(self.interest, 0.0) < 0:
+ raise UserError(self.env._('The principal and the interest paid cannot be negative.'))
+ changes = {fname: self[fname] for fname in ('principal', 'interest')
+ if currency.compare_amounts(self[fname], line[fname])}
+ if changes:
+ line.write(changes)
+ if currency.compare_amounts(self.withholding, 0.0) < 0 or \
+ currency.compare_amounts(self.withholding, max(self.interest, 0.0)) > 0:
+ raise UserError(self.env._('The tax withheld must be between zero and the interest.'))
+ line._post_payment(self.date, self.journal_id, withholding=self.withholding)
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/om_account_loan/wizard/account_loan_pay_views.xml b/om_account_loan/wizard/account_loan_pay_views.xml
new file mode 100644
index 000000000..ee75822ac
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_pay_views.xml
@@ -0,0 +1,41 @@
+
+
+
+
+ account.loan.pay.form
+ account.loan.pay
+
+
+
+
+
+
diff --git a/om_account_loan/wizard/account_loan_reclassify.py b/om_account_loan/wizard/account_loan_reclassify.py
new file mode 100644
index 000000000..17ff57796
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_reclassify.py
@@ -0,0 +1,52 @@
+from dateutil.relativedelta import relativedelta
+
+from odoo import fields, models
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+
+
+def _last_month_end(wizard):
+ return fields.Date.context_today(wizard) + relativedelta(day=1, days=-1)
+
+
+class AccountLoanReclassify(models.TransientModel):
+ _name = 'account.loan.reclassify'
+ _description = 'Loan Current / Non-current Reclassification'
+
+ date = fields.Date(
+ string='Date', required=True, default=_last_month_end,
+ help="The end of the period: the principal due within 12 months after it goes to the short-term account.")
+ company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
+ loan_ids = fields.Many2many(
+ 'account.loan', string='Loans and Leases', required=True,
+ domain="[('company_id', '=', company_id), ('state', 'in', ('running', 'closed'))]",
+ default=lambda self: self.env['account.loan'].search([
+ ('company_id', '=', self.env.company.id), ('state', '=', 'running')]))
+
+ def action_confirm(self):
+ """ Post, per loan, the move between the long-term and the short-term accounts that leaves the
+ principal due within 12 months on the short-term account. """
+ self.ensure_one()
+ if self.date > fields.Date.context_today(self):
+ raise UserError(self.env._(
+ 'Reclassify at the end of a period that is over: %s is in the future.',
+ format_date(self.env, self.date)))
+ loans = self.loan_ids.filtered(lambda loan: loan.company_id == self.company_id)
+ not_running = loans.filtered(lambda loan: loan.state not in ('running', 'closed'))
+ if not_running:
+ raise UserError(self.env._(
+ 'Only running or closed loans are reclassified: %s.', ', '.join(not_running.mapped('display_name'))))
+ moves = self.env['account.move']
+ for loan in loans:
+ moves |= loan._reclassify(self.date)
+ if not moves:
+ raise UserError(self.env._(
+ 'The short-term balances are right on %s: nothing to reclassify.', format_date(self.env, self.date)))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.env._('Reclassification'),
+ 'res_model': 'account.move',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', moves.ids)],
+ 'context': {'create': False},
+ }
diff --git a/om_account_loan/wizard/account_loan_reclassify_views.xml b/om_account_loan/wizard/account_loan_reclassify_views.xml
new file mode 100644
index 000000000..6d2960daa
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_reclassify_views.xml
@@ -0,0 +1,36 @@
+
+
+
+
+ account.loan.reclassify.form
+ account.loan.reclassify
+
+
+
+
+
+
+ Reclassification
+ account.loan.reclassify
+ form
+ new
+
+
+
diff --git a/om_account_loan/wizard/account_loan_repay.py b/om_account_loan/wizard/account_loan_repay.py
new file mode 100644
index 000000000..2b4b3c85f
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_repay.py
@@ -0,0 +1,157 @@
+import math
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+
+from odoo.addons.om_account_loan.models.account_loan import annuity_count
+from odoo.addons.om_account_loan.models.loan_context import NO_REGENERATE, SCHEDULE_UPDATE, internal_context
+
+
+class AccountLoanRepay(models.TransientModel):
+ _name = 'account.loan.repay'
+ _description = 'Loan Early Repayment'
+ _check_company_auto = True
+
+ loan_id = fields.Many2one('account.loan', string='Loan', required=True)
+ company_id = fields.Many2one(related='loan_id.company_id')
+ currency_id = fields.Many2one(related='loan_id.currency_id')
+ outstanding_amount = fields.Monetary(related='loan_id.outstanding_amount')
+ date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
+ amount = fields.Monetary(
+ string='Principal Repaid', required=True, compute='_compute_amount', store=True, readonly=False,
+ precompute=True)
+ interest = fields.Monetary(
+ string='Interest Due', compute='_compute_interest', store=True, readonly=False, precompute=True,
+ help="The interest accrued since the last due date, paid with the principal.")
+ withholding_tax_id = fields.Many2one(string='Withholding Tax Rule', related='loan_id.withholding_tax_id')
+ withholding = fields.Monetary(
+ string='Withholding Tax', compute='_compute_withholding', store=True, readonly=False, precompute=True,
+ help="The tax withheld on the interest, deducted from the payment to the lender.")
+ total = fields.Monetary(string='Total', compute='_compute_total')
+ is_full = fields.Boolean(string='Full Repayment', compute='_compute_total')
+ after_repayment = fields.Selection(
+ [('term', 'Shorter Term'), ('instalment', 'Lower Instalment')], string='Then', required=True,
+ default='instalment',
+ help="Shorter Term: the instalments stay about the same, the loan ends earlier.\n"
+ "Lower Instalment: the loan ends on the same date with lower instalments.")
+ journal_id = fields.Many2one(
+ 'account.journal', string='Bank Journal', required=True, check_company=True,
+ compute='_compute_journal_id', store=True, readonly=False, precompute=True,
+ domain="[('type', 'in', ('bank', 'cash'))]")
+
+ @api.depends('loan_id')
+ def _compute_amount(self):
+ # to enter: a full repayment closes the loan, it is not the default
+ for wizard in self:
+ wizard.amount = 0.0
+
+ @api.depends('loan_id', 'date')
+ def _compute_interest(self):
+ for wizard in self:
+ loan = wizard.loan_id
+ wizard.interest = loan._accrued_interest_at(wizard.date) if loan and wizard.date else 0.0
+
+ @api.depends('loan_id', 'interest')
+ def _compute_withholding(self):
+ for wizard in self:
+ wizard.withholding = wizard.loan_id._withholding_amount(wizard.interest) if wizard.loan_id else 0.0
+
+ @api.depends('amount', 'interest', 'outstanding_amount')
+ def _compute_total(self):
+ for wizard in self:
+ wizard.total = wizard.amount + wizard.interest
+ wizard.is_full = not wizard.currency_id.compare_amounts(wizard.amount, wizard.outstanding_amount)
+
+ @api.depends('loan_id')
+ def _compute_journal_id(self):
+ for wizard in self:
+ wizard.journal_id = wizard.loan_id.bank_journal_id
+
+ def _check_repayment(self):
+ loan = self.loan_id
+ currency = loan.currency_id
+ if loan.state != 'running':
+ raise UserError(self.env._('The loan %s is not running.', loan.display_name))
+ if loan.loan_type == 'lease':
+ raise UserError(self.env._('A lease is not repaid early: its payments are billed by the lessor.'))
+ if currency.compare_amounts(self.withholding, 0.0) < 0 or \
+ currency.compare_amounts(self.withholding, max(self.interest, 0.0)) > 0:
+ raise UserError(self.env._('The tax withheld must be between zero and the interest.'))
+ if currency.compare_amounts(self.amount, 0.0) <= 0 or currency.compare_amounts(self.interest, 0.0) < 0:
+ raise UserError(self.env._('The principal repaid must be positive, the interest too or zero.'))
+ if currency.compare_amounts(self.amount, loan.outstanding_amount) > 0:
+ raise UserError(self.env._(
+ 'The outstanding principal of the loan %(loan)s is %(amount)s.',
+ loan=loan.display_name, amount=currency.format(loan.outstanding_amount)))
+ loan._check_paid_up_to(self.date)
+ due = loan.line_ids.filtered(lambda line: line.state == 'draft' and line.date <= self.date)
+ if due:
+ raise UserError(self.env._(
+ 'Pay the instalments due on or before %s first.', format_date(self.env, self.date)))
+ if self.journal_id.currency_id and self.journal_id.currency_id != currency:
+ raise UserError(self.env._('The bank journal is not in the currency of the loan.'))
+
+ def _shorter_count(self, balance):
+ """ The number of payments of the loan once `balance` is left, the next instalment (equal
+ instalments) or principal (fixed principal) staying the same. """
+ loan = self.loan_id
+ paid = loan.line_ids.filtered(lambda line: line.state == 'posted')
+ unpaid = loan.line_ids.filtered(lambda line: line.state == 'draft').sorted('sequence')
+ following = unpaid[:1]
+ if not following:
+ return loan.payment_count
+ if loan.schedule_method == 'annuity':
+ rate = loan._rate_at(self.date) / 100 / loan._payments_per_year()
+ count = annuity_count(balance, rate, following.amount)
+ else:
+ count = following.principal and math.ceil(balance / following.principal - 1e-9)
+ return len(paid) + min(count or len(unpaid), len(unpaid))
+
+ def action_confirm(self):
+ """ Post Dr loan (principal) + Dr interest / Cr withholding tax + Cr bank, then compute the unpaid
+ lines again: fewer of them for a shorter term, lower ones otherwise. A full repayment closes the
+ loan. """
+ self.ensure_one()
+ self._check_repayment()
+ loan = self.loan_id
+ currency = loan.currency_id
+ label = self.env._('%(loan)s - Early repayment', loan=loan.name)
+ lines = loan._principal_lines(self.amount, self.date, label)
+ lines.append((loan.account_interest_id, self.interest,
+ self.env._('%(loan)s - Interest to %(date)s', loan=loan.name,
+ date=format_date(self.env, self.date)),
+ loan._interest_line_values() if self.withholding else {}))
+ lines += loan._withholding_lines(
+ self.interest, self.withholding, self.date,
+ self.env._('%(loan)s - Withholding tax to %(date)s', loan=loan.name, date=format_date(self.env, self.date)))
+ lines.append((loan._bank_account(self.journal_id, 'outbound'), 0.0, label))
+ move = loan._create_move(self.journal_id, self.date, lines, 'repayment', label)
+ balance = currency.round(loan.outstanding_amount - self.amount)
+ count = loan.payment_count
+ if not self.is_full and self.after_repayment == 'term':
+ count = self._shorter_count(balance)
+ self.env['account.loan.operation'].create({
+ 'loan_id': loan.id,
+ 'operation_type': 'repayment',
+ 'date': self.date,
+ 'amount': self.amount,
+ 'interest': self.interest,
+ 'withholding': self.withholding,
+ 'journal_id': self.journal_id.id,
+ 'move_id': move.id,
+ 'after_repayment': self.after_repayment,
+ 'payment_count_before': loan.payment_count,
+ })
+ if self.is_full:
+ unpaid = loan.line_ids.filtered(lambda line: line.state == 'draft')
+ unpaid.with_context(**internal_context(SCHEDULE_UPDATE)).unlink()
+ else:
+ if count != loan.payment_count:
+ loan.with_context(**internal_context(NO_REGENERATE)).payment_count = count
+ loan._generate_schedule()
+ loan._update_state()
+ loan.message_post(body=self.env._(
+ 'Early repayment of %(amount)s on %(date)s.', amount=currency.format(self.amount),
+ date=format_date(self.env, self.date)))
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/om_account_loan/wizard/account_loan_repay_views.xml b/om_account_loan/wizard/account_loan_repay_views.xml
new file mode 100644
index 000000000..3ad772f7a
--- /dev/null
+++ b/om_account_loan/wizard/account_loan_repay_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ account.loan.repay.form
+ account.loan.repay
+
+
+
+
+
+
diff --git a/om_account_loan_asset/README.rst b/om_account_loan_asset/README.rst
new file mode 100644
index 000000000..199028e00
--- /dev/null
+++ b/om_account_loan_asset/README.rst
@@ -0,0 +1,78 @@
+===========================
+Leases: Right-of-use Assets
+===========================
+
+This module links the leases of Loans and Leases (om_account_loan) with Assets
+Management (om_account_asset) in Odoo 20 Community Edition: the right-of-use
+asset of a lease is created on its recognition and depreciated over the lease
+term. It is installed automatically when both modules are installed.
+
+Features
+========
+
+* A Right-of-use Asset Category on the lease (an asset category of the
+ company). The initial recognition of the lease debits the asset account of
+ the category: Dr Right-of-use asset / Cr Long-term lease liability, at the
+ present value of the lease payments.
+* The right-of-use asset is created at the same time: its gross value is the
+ lease liability (in the company currency, at the rate of the start date),
+ from the start of the lease, depreciated linearly, prorata temporis, to the
+ end of the lease term (the last due date, or one period after it for a lease
+ paid in advance), with the journal, the accounts and the period of the
+ category. It is confirmed when the category confirms its assets
+ (Auto-Confirm Assets), else left in draft to check.
+* The asset shows on the lease (Right of Use button); cancelling the
+ recognition of the lease cancels it (a draft asset is deleted).
+
+For example, a lease of 12 monthly payments of 1,000.00 at 6% from
+1 January 2025 is recognized at 11,618.93: its asset in a monthly category
+depreciates 968.24 at the end of each month of 2025.
+
+Without a category, the lease debits its Right-of-use Asset Account and no
+asset is created.
+
+Installation
+============
+
+To install this module, you need to:
+
+Download the module and add it to your Odoo addons folder. Afterward, log on to
+your Odoo server and go to the Apps menu. Trigger the debug mode and update the
+list by clicking on the "Update Apps List" link. It is installed with Loans and
+Leases and Assets Management; install it if they are already installed.
+
+Upgrade
+=======
+
+To upgrade this module, you need to:
+
+Download the module and add it to your Odoo addons folder. Restart the server
+and log on to your Odoo server. Select the Apps menu and upgrade the module by
+clicking on the upgrade button.
+
+Configuration
+=============
+
+Create an asset category for the rights of use (Accounting > Configuration >
+Asset Categories), with the right-of-use asset account, its accumulated
+depreciation and depreciation expense accounts, the journal and the period of
+the depreciations.
+
+Usage
+=====
+
+On the lease, in the Accounting tab, choose the Right-of-use Asset Category,
+then confirm the lease.
+
+Credits
+=======
+
+Contributors
+------------
+
+* Odoo Mates
+
+Author & Maintainer
+-------------------
+
+This module is maintained by the Odoo Mates
diff --git a/om_account_loan_asset/__init__.py b/om_account_loan_asset/__init__.py
new file mode 100644
index 000000000..0650744f6
--- /dev/null
+++ b/om_account_loan_asset/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/om_account_loan_asset/__manifest__.py b/om_account_loan_asset/__manifest__.py
new file mode 100644
index 000000000..575317168
--- /dev/null
+++ b/om_account_loan_asset/__manifest__.py
@@ -0,0 +1,20 @@
+{
+ 'name': 'Leases: Right-of-use Assets',
+ 'version': '1.0.0',
+ 'category': 'Accounting',
+ 'summary': 'The right-of-use asset of a lease, created at the lease liability and depreciated over the '
+ 'lease term',
+ 'description': 'Bridge between Loans and Leases and Assets Management: on the recognition of a lease, its '
+ 'right-of-use asset is created in the asset category of the lease and depreciated over the '
+ 'lease term.',
+ 'author': 'Odoo Mates',
+ 'maintainer': 'Odoo Mates',
+ 'support': 'odoomates@gmail.com',
+ 'license': 'LGPL-3',
+ 'images': ['static/description/banner.png'],
+ 'depends': ['om_account_loan', 'om_account_asset'],
+ 'data': [
+ 'views/account_loan_views.xml',
+ ],
+ 'auto_install': True,
+}
diff --git a/om_account_loan_asset/models/__init__.py b/om_account_loan_asset/models/__init__.py
new file mode 100644
index 000000000..f3fa8abdb
--- /dev/null
+++ b/om_account_loan_asset/models/__init__.py
@@ -0,0 +1,2 @@
+from . import account_loan
+from . import account_loan_operation
diff --git a/om_account_loan_asset/models/account_loan.py b/om_account_loan_asset/models/account_loan.py
new file mode 100644
index 000000000..77d3d8a7c
--- /dev/null
+++ b/om_account_loan_asset/models/account_loan.py
@@ -0,0 +1,95 @@
+from markupsafe import Markup
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError, ValidationError
+
+from odoo.addons.om_account_loan.models.account_loan import ONE_DAY, add_months
+
+
+class AccountLoan(models.Model):
+ _inherit = 'account.loan'
+
+ asset_category_id = fields.Many2one(
+ 'account.asset.category', string='Right-of-use Asset Category', tracking=True,
+ domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
+ groups='account.group_account_readonly,account.group_account_manager',
+ help="Create the right-of-use asset of the lease in this category on its recognition, at the lease "
+ "liability, depreciated over the lease term. The initial recognition debits the asset account of "
+ "the category.")
+ asset_id = fields.Many2one(
+ 'account.asset.asset', string='Right-of-use Asset', readonly=True, copy=False,
+ groups='account.group_account_readonly,account.group_account_manager')
+
+ @api.constrains('asset_category_id', 'company_id')
+ def _check_asset_category(self):
+ for loan in self.sudo():
+ category = loan.asset_category_id
+ if category and (category.company_id != loan.company_id or category.type != 'purchase'):
+ raise ValidationError(self.env._(
+ 'The asset category %(category)s cannot hold the right of use of %(lease)s: choose an asset '
+ 'category of its company.', category=category.display_name, lease=loan.display_name))
+
+ @api.onchange('asset_category_id')
+ def _onchange_asset_category_id(self):
+ if self.asset_category_id:
+ self.account_rou_id = self.asset_category_id.account_asset_id
+
+ def write(self, vals):
+ if 'asset_category_id' in vals and any(loan._has_entries() for loan in self):
+ raise UserError(self.env._('The asset category of a recognized lease cannot change.'))
+ return super().write(vals)
+
+ def _rou_account(self):
+ """ The asset account of the category of the right-of-use asset, when the lease has one. """
+ self.ensure_one()
+ return self.asset_category_id.account_asset_id or super()._rou_account()
+
+ def _recognize_lease(self):
+ """ Create the right-of-use asset with the recognition of the lease. """
+ operation = super()._recognize_lease()
+ if self.asset_category_id:
+ self.asset_id = self._create_rou_asset()
+ return operation
+
+ def _prepare_rou_asset_values(self):
+ """ The values of the right-of-use asset: the lease liability in the company currency, depreciated
+ linearly from the start of the lease to the end of its term, in the periods of the category. """
+ self.ensure_one()
+ category = self.asset_category_id
+ company = self.company_id
+ currency = company.currency_id
+ Asset = self.env['account.asset.asset']
+ vals = {
+ 'name': self.env._('%(lease)s: right of use', lease=self.name),
+ 'code': self.name,
+ 'category_id': category.id,
+ 'value': currency.round(self.currency_id._convert(self.principal, currency, company, self.date_start)),
+ 'partner_id': self.partner_id.id,
+ 'company_id': company.id,
+ 'currency_id': currency.id,
+ 'date': self.date_start,
+ **Asset.onchange_category_id_values(category.id)['value'],
+ }
+ vals.update({
+ 'method': 'linear',
+ 'method_time': 'end',
+ 'method_end': self._lease_term_end() - ONE_DAY,
+ 'prorata': True,
+ })
+ if vals.get('date_first_depreciation') == 'manual':
+ # the periods of the asset follow the lease
+ vals['first_depreciation_manual_date'] = add_months(self.date_start, vals['method_period']) - ONE_DAY
+ return vals
+
+ def _create_rou_asset(self):
+ """ Create the right-of-use asset of the lease, confirmed when its category confirms its assets. """
+ self.ensure_one()
+ asset = self.env['account.asset.asset'].create(self._prepare_rou_asset_values())
+ if self.asset_category_id.open_asset:
+ asset.validate()
+ self.message_post(body=Markup(self.env._('Right-of-use asset %s created.')) % asset._get_html_link())
+ return asset
+
+ def action_view_rou_asset(self):
+ self.ensure_one()
+ return self.asset_id._get_assets_action(self.env._('Right-of-use Asset'))
diff --git a/om_account_loan_asset/models/account_loan_operation.py b/om_account_loan_asset/models/account_loan_operation.py
new file mode 100644
index 000000000..9b4477499
--- /dev/null
+++ b/om_account_loan_asset/models/account_loan_operation.py
@@ -0,0 +1,19 @@
+from odoo import models
+
+
+class AccountLoanOperation(models.Model):
+ _inherit = 'account.loan.operation'
+
+ def action_cancel(self):
+ """ Cancel the right-of-use asset with the recognition of its lease. """
+ recognitions = self.filtered(lambda op: op.state == 'posted' and op.operation_type == 'recognition')
+ leases = recognitions.loan_id.filtered('asset_id')
+ res = super().action_cancel()
+ for lease in leases:
+ asset = lease.asset_id
+ if asset.state == 'draft':
+ asset.unlink()
+ elif asset.state != 'cancelled':
+ asset.set_to_cancelled()
+ lease.asset_id = False
+ return res
diff --git a/om_account_loan_asset/static/description/banner.png b/om_account_loan_asset/static/description/banner.png
new file mode 100644
index 000000000..6cd1f9687
Binary files /dev/null and b/om_account_loan_asset/static/description/banner.png differ
diff --git a/om_account_loan_asset/static/description/icon.png b/om_account_loan_asset/static/description/icon.png
new file mode 100644
index 000000000..2e5532396
Binary files /dev/null and b/om_account_loan_asset/static/description/icon.png differ
diff --git a/om_account_loan_asset/static/description/index.html b/om_account_loan_asset/static/description/index.html
new file mode 100644
index 000000000..b35865084
--- /dev/null
+++ b/om_account_loan_asset/static/description/index.html
@@ -0,0 +1,112 @@
+
+
+
Odoo 20 Community · Free · LGPL-3 · Bridge
+
Leases: Right-of-use Assets
+
Links Loans and Leases with Assets Management: the right-of-use asset of each lease is created when the lease is recognized, at the lease liability, and depreciated over the lease term.
+
+Installs by itself
+Asset at the lease liability
+Depreciated over the lease term
+
A Right-of-use Asset Category on the lease (Accounting tab): the recognition debits the asset account of the category, Dr Right-of-use asset / Cr Long-term lease liability.
+
+
+
✓
+
The asset is created at the same time: its value is the lease liability (in the company currency), from the start of the lease.
+
+
+
✓
+
Depreciated linearly, prorata temporis, to the end of the lease term, with the journal, the accounts and the period of the category.
+
+
+
✓
+
Confirmed when the category confirms its assets, else left in draft to check.
+
+
+
✓
+
The Right of Use button of the lease opens it; cancelling the recognition cancels it.
+
+
+
+
+
+
+
Example
+
+
+
Lease
+
12 monthly payments of 1,000.00 at 6% from 1 January 2025
+
+
+
Lease liability
+
11,618.93
+
+
+
Asset
+
11,618.93, depreciated 968.24 at the end of each month of 2025
+
+
+
Without a category, the lease debits its Right-of-use Asset Account and no asset is created.
+
+
+
+
+
+
The lease: the Right of Use button at the top, the asset category in the Accounting tab.
+
+
+
+
The right-of-use asset of a 36-month lease: one depreciation a month to the end of the lease term.
+
+
+
+
The asset: linear, prorata temporis, ending with the lease.
+
+
+
Setup
+
One asset category
+
Create an asset category for the rights of use (Invoicing › Configuration › Asset Categories): the right-of-use asset account, its accumulated depreciation and depreciation expense accounts, the journal and the period of the depreciations. Then choose it on the lease before confirming it.
It installs by itself when both apps are installed. If they already are, install it from the Apps menu (developer mode, Update Apps List, search om_account_loan_asset).
+
+
+
✓
+
The menus are under Invoicing › Accounting, shown to the users with Show Full Accounting Features.
diff --git a/om_account_loan_asset/static/description/lease_rou_category.png b/om_account_loan_asset/static/description/lease_rou_category.png
new file mode 100644
index 000000000..d252bd767
Binary files /dev/null and b/om_account_loan_asset/static/description/lease_rou_category.png differ
diff --git a/om_account_loan_asset/static/description/odoo_mates.png b/om_account_loan_asset/static/description/odoo_mates.png
new file mode 100644
index 000000000..8408fa3a8
Binary files /dev/null and b/om_account_loan_asset/static/description/odoo_mates.png differ
diff --git a/om_account_loan_asset/static/description/rou_asset.png b/om_account_loan_asset/static/description/rou_asset.png
new file mode 100644
index 000000000..0f35334e0
Binary files /dev/null and b/om_account_loan_asset/static/description/rou_asset.png differ
diff --git a/om_account_loan_asset/static/description/rou_asset_board.png b/om_account_loan_asset/static/description/rou_asset_board.png
new file mode 100644
index 000000000..599bcaa04
Binary files /dev/null and b/om_account_loan_asset/static/description/rou_asset_board.png differ
diff --git a/om_account_loan_asset/tests/__init__.py b/om_account_loan_asset/tests/__init__.py
new file mode 100644
index 000000000..c09db4ccc
--- /dev/null
+++ b/om_account_loan_asset/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_rou_asset
diff --git a/om_account_loan_asset/tests/test_rou_asset.py b/om_account_loan_asset/tests/test_rou_asset.py
new file mode 100644
index 000000000..7455b6a4e
--- /dev/null
+++ b/om_account_loan_asset/tests/test_rou_asset.py
@@ -0,0 +1,95 @@
+from datetime import date
+
+from freezegun import freeze_time
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from odoo.addons.om_account_loan.tests.common import LoanCommon
+
+
+@tagged('post_install', '-at_install')
+class TestRightOfUseAsset(LoanCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ Account = cls.env['account.account']
+ cls.category_account = Account.create({
+ 'code': '152800', 'name': 'Right-of-use Buildings', 'account_type': 'asset_non_current'})
+ cls.accumulated_account = Account.create({
+ 'code': '152890', 'name': 'Right-of-use Accumulated Depreciation', 'account_type': 'asset_non_current'})
+ cls.depreciation_account = Account.create({
+ 'code': '681800', 'name': 'Right-of-use Depreciation', 'account_type': 'expense_depreciation'})
+ cls.category = cls.env['account.asset.category'].create({
+ 'name': 'Leased Offices',
+ 'type': 'purchase',
+ 'journal_id': cls.misc_journal.id,
+ 'account_asset_id': cls.category_account.id,
+ 'account_depreciation_id': cls.accumulated_account.id,
+ 'account_depreciation_expense_id': cls.depreciation_account.id,
+ 'method_number': 60,
+ 'method_period': 1,
+ 'date_first_depreciation': 'last_day_period',
+ 'open_asset': True,
+ })
+
+ @freeze_time('2025-01-01')
+ def test_rou_asset_created(self):
+ """ The recognition debits the asset account of the category and creates the asset at the lease
+ liability, depreciated monthly over the 12 months of the lease term. On the start of the lease, the
+ asset is still running (once the term is over, it is closed). """
+ lease = self._create_lease(asset_category_id=self.category.id)
+ recognition = lease.operation_ids.move_id
+ self.assertEqual(self._entry(recognition), {
+ self.category_account.code: (11618.93, 0.0, 11618.93),
+ self.lease_long_term_account.code: (0.0, 11618.93, -11618.93),
+ })
+ asset = lease.asset_id
+ self.assertTrue(asset)
+ self.assertEqual((asset.category_id, asset.value, asset.date), (self.category, 11618.93, date(2025, 1, 1)))
+ self.assertEqual((asset.partner_id, asset.code, asset.currency_id), (self.lessor, lease.name,
+ self.env.company.currency_id))
+ self.assertEqual((asset.method, asset.method_time, asset.method_end, asset.prorata),
+ ('linear', 'end', date(2025, 12, 31), True))
+ self.assertEqual(asset.state, 'open')
+ board = asset._get_board_moves().sorted('date')
+ self.assertEqual(len(board), 12)
+ self.assertEqual((board[0].date, board[-1].date), (date(2025, 1, 31), date(2025, 12, 31)))
+ self.assertAlmostEqual(sum(board.mapped('asset_depreciation_amount')), 11618.93, places=2)
+ self.assertEqual(board[0].asset_depreciation_amount, 968.24)
+ action = lease.action_view_rou_asset()
+ self.assertEqual(action['res_id'], asset.id)
+
+ def test_without_category(self):
+ """ Without a category, no asset: the right-of-use account of the lease is debited. """
+ lease = self._create_lease()
+ self.assertFalse(lease.asset_id)
+ self.assertIn(self.rou_account, lease.operation_ids.move_id.line_ids.account_id)
+
+ def test_draft_asset(self):
+ """ A category that does not confirm its assets leaves the asset in draft, to check. """
+ self.category.open_asset = False
+ lease = self._create_lease(asset_category_id=self.category.id)
+ self.assertEqual(lease.asset_id.state, 'draft')
+
+ def test_cancel_recognition_cancels_asset(self):
+ lease = self._create_lease(asset_category_id=self.category.id)
+ asset = lease.asset_id
+ lease.operation_ids.action_cancel()
+ self.assertEqual(asset.state, 'cancelled')
+ self.assertFalse(lease.asset_id)
+ lease.action_recognize()
+ self.assertTrue(lease.asset_id)
+ self.assertNotEqual(lease.asset_id, asset)
+
+ def test_category_locked(self):
+ lease = self._create_lease(asset_category_id=self.category.id)
+ with self.assertRaises(UserError):
+ lease.asset_category_id = False
+
+ def test_paid_in_advance_term(self):
+ """ A lease paid in advance runs one period after its last payment. """
+ lease = self._create_lease(asset_category_id=self.category.id, date_first_payment=date(2025, 1, 1))
+ self.assertEqual(lease.asset_id.method_end, date(2025, 12, 31))
+ self.assertEqual(lease.asset_id.value, 11677.03)
diff --git a/om_account_loan_asset/views/account_loan_views.xml b/om_account_loan_asset/views/account_loan_views.xml
new file mode 100644
index 000000000..b524f5909
--- /dev/null
+++ b/om_account_loan_asset/views/account_loan_views.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ account.loan.form.inherit.om_account_loan_asset
+ account.loan
+
+
+
+
+
+
+
+
+ state != 'draft' or asset_category_id
+ 1
+
+
+
+
+
+
+
+
diff --git a/om_account_loan_cash_forecast/README.rst b/om_account_loan_cash_forecast/README.rst
new file mode 100644
index 000000000..26c43c358
--- /dev/null
+++ b/om_account_loan_cash_forecast/README.rst
@@ -0,0 +1,54 @@
+=====================================
+Loans and Leases in the Cash Forecast
+=====================================
+
+This module links Loans and Leases (om_account_loan) with Cash Forecast
+(om_account_cash_forecast) in Odoo 20 Community Edition: the instalments and
+the lease payments still to come are expected in the cash forecast. It is
+installed automatically when both modules are installed.
+
+Features
+========
+
+* Loan instalments: the instalments of the running loans not paid yet, on
+ their due dates, for the instalment less the tax withheld (what leaves the
+ bank), on a row of their own in the payments.
+* Lease payments not yet billed: the payments of the running leases not billed
+ yet, on their due dates, with the taxes of the bills (and the tax withheld on
+ the interest). Once billed, a lease payment is a vendor bill of the forecast,
+ expected on the due date of the bill.
+* In the currency of the company, at the rate of the starting date of the
+ forecast, as the other items. The instalments already due follow the Overdue
+ option of the forecast.
+* The draft and closed loans, and the paid instalments (in the bank already),
+ are left out.
+
+Installation
+============
+
+To install this module, you need to:
+
+Download the module and add it to your Odoo addons folder. Afterward, log on to
+your Odoo server and go to the Apps menu. Trigger the debug mode and update the
+list by clicking on the "Update Apps List" link. It installs by itself when
+Loans and Leases and Cash Forecast are both installed.
+
+Usage
+=====
+
+Print the cash forecast (Accounting > Reporting > Management > Cash Forecast):
+the Loan instalments and Lease payments not yet billed rows show with the
+other payments.
+
+Credits
+=======
+
+Contributors
+------------
+
+* Odoo Mates
+
+Author & Maintainer
+-------------------
+
+This module is maintained by the Odoo Mates
diff --git a/om_account_loan_cash_forecast/__init__.py b/om_account_loan_cash_forecast/__init__.py
new file mode 100644
index 000000000..4c4f242fa
--- /dev/null
+++ b/om_account_loan_cash_forecast/__init__.py
@@ -0,0 +1 @@
+from . import report
diff --git a/om_account_loan_cash_forecast/__manifest__.py b/om_account_loan_cash_forecast/__manifest__.py
new file mode 100644
index 000000000..198cd827b
--- /dev/null
+++ b/om_account_loan_cash_forecast/__manifest__.py
@@ -0,0 +1,16 @@
+{
+ 'name': 'Loans and Leases in the Cash Forecast',
+ 'version': '1.0.0',
+ 'category': 'Accounting',
+ 'summary': 'The instalments of the loans and the payments of the leases in the cash forecast',
+ 'description': 'Bridge between Loans and Leases and Cash Forecast: the instalments still to pay of the '
+ 'running loans and the lease payments still to bill are expected in the cash forecast on '
+ 'their due dates.',
+ 'author': 'Odoo Mates',
+ 'maintainer': 'Odoo Mates',
+ 'support': 'odoomates@gmail.com',
+ 'license': 'LGPL-3',
+ 'images': ['static/description/banner.png'],
+ 'depends': ['om_account_loan', 'om_account_cash_forecast'],
+ 'auto_install': True,
+}
diff --git a/om_account_loan_cash_forecast/report/__init__.py b/om_account_loan_cash_forecast/report/__init__.py
new file mode 100644
index 000000000..889e1e18b
--- /dev/null
+++ b/om_account_loan_cash_forecast/report/__init__.py
@@ -0,0 +1 @@
+from . import report_cash_forecast
diff --git a/om_account_loan_cash_forecast/report/report_cash_forecast.py b/om_account_loan_cash_forecast/report/report_cash_forecast.py
new file mode 100644
index 000000000..2159706e5
--- /dev/null
+++ b/om_account_loan_cash_forecast/report/report_cash_forecast.py
@@ -0,0 +1,64 @@
+from odoo import _, api, models
+
+
+class ReportCashForecast(models.AbstractModel):
+ _inherit = 'report.om_account_cash_forecast.report_cash_forecast'
+
+ @api.model
+ def _get_row_keys(self):
+ receipts, payments = super()._get_row_keys()
+ # before the items entered by hand
+ index = payments.index('expected_out') if 'expected_out' in payments else len(payments)
+ payments[index:index] = ['loans_out', 'leases_out']
+ return receipts, payments
+
+ @api.model
+ def _get_row_labels(self):
+ return {
+ **super()._get_row_labels(),
+ 'loans_out': _('Loan instalments'),
+ 'leases_out': _('Lease payments not yet billed'),
+ }
+
+ @api.model
+ def _get_forecast_items(self, wizard, date_to):
+ """ The instalments of the running loans still to pay, net of the tax withheld, and the payments of the
+ running leases not billed yet, taxes of the bills included, on their due dates. The bills already
+ created are vendor bills of the forecast. """
+ items = super()._get_forecast_items(wizard, date_to)
+ company = wizard.company_id
+ currency = company.currency_id
+ lines = self.env['account.loan.line'].search([
+ ('company_id', 'child_of', company.id),
+ ('state', '=', 'draft'),
+ ('loan_id.state', '=', 'running'),
+ ('date', '<=', date_to),
+ ])
+ for line in lines:
+ loan = line.loan_id
+ if loan.loan_type == 'lease':
+ amount = self._lease_payment_with_taxes(line)
+ row = 'leases_out'
+ else:
+ amount = line.net_amount
+ row = 'loans_out'
+ if loan.currency_id.is_zero(amount):
+ continue
+ items.append({
+ 'date': line.date,
+ 'amount': -loan.currency_id._convert(amount, currency, company, wizard.date_from),
+ 'row': row,
+ 'partner': loan.partner_id.commercial_partner_id.name or loan.name,
+ })
+ return items
+
+ @api.model
+ def _lease_payment_with_taxes(self, line):
+ """ What the bill of the lease payment `line` will ask: the principal and the interest with the taxes of
+ the bills, the interest less the tax withheld, as the bill splits them. """
+ loan = line.loan_id
+ currency = loan.currency_id
+ total = loan.bill_tax_ids.compute_all(line.principal, currency=currency)['total_included']
+ total += (loan.bill_tax_ids | loan.withholding_tax_id).compute_all(
+ line.interest, currency=currency)['total_included']
+ return currency.round(total)
diff --git a/om_account_loan_cash_forecast/static/description/banner.png b/om_account_loan_cash_forecast/static/description/banner.png
new file mode 100644
index 000000000..6e62838e1
Binary files /dev/null and b/om_account_loan_cash_forecast/static/description/banner.png differ
diff --git a/om_account_loan_cash_forecast/static/description/cash_forecast_loans.png b/om_account_loan_cash_forecast/static/description/cash_forecast_loans.png
new file mode 100644
index 000000000..ccba1b4d5
Binary files /dev/null and b/om_account_loan_cash_forecast/static/description/cash_forecast_loans.png differ
diff --git a/om_account_loan_cash_forecast/static/description/cash_forecast_partners.png b/om_account_loan_cash_forecast/static/description/cash_forecast_partners.png
new file mode 100644
index 000000000..4f1e74a44
Binary files /dev/null and b/om_account_loan_cash_forecast/static/description/cash_forecast_partners.png differ
diff --git a/om_account_loan_cash_forecast/static/description/icon.png b/om_account_loan_cash_forecast/static/description/icon.png
new file mode 100644
index 000000000..2e5532396
Binary files /dev/null and b/om_account_loan_cash_forecast/static/description/icon.png differ
diff --git a/om_account_loan_cash_forecast/static/description/index.html b/om_account_loan_cash_forecast/static/description/index.html
new file mode 100644
index 000000000..9576fa27e
--- /dev/null
+++ b/om_account_loan_cash_forecast/static/description/index.html
@@ -0,0 +1,94 @@
+
+
+
Odoo 20 Community · Free · LGPL-3 · Bridge
+
Loans and Leases in the Cash Forecast
+
Links Loans and Leases with Cash Forecast: the instalments still to pay and the lease payments still to bill are expected in the cash forecast on their due dates.
+
+Installs by itself
+Loan instalments
+Lease payments
+
Loan instalments: the unpaid instalments of the running loans, on their due dates, for the instalment less the tax withheld (what leaves the bank).
+
+
+
✓
+
Lease payments not yet billed: the payments of the running leases, on their due dates, with the taxes of the bills. Once billed, a payment is a vendor bill of the forecast.
+
+
+
+
+
+
+
✓
+
In the company currency, at the rate of the starting date of the forecast, as the other items.
+
+
+
✓
+
The instalments already due follow the Overdue option of the forecast.
+
+
+
✓
+
Draft and closed loans, and paid instalments (in the bank already), are left out.
+
+
+
✓
+
Detail by Partner shows each lender and lessor.
+
+
+
+
+
+
+
+
The cash forecast by month: Loan instalments and Lease payments not yet billed among the payments.
+
+
+
+
Detail by Partner: each lender and lessor on its own line.
Take the latest Odoo 20 Cash Forecast: the bridge needs what this release adds to it (the rows of the forecast are extendable). Download it again, replace its folder and upgrade it first.
+
+
✓
+
It installs by itself when both apps are installed. If they already are, install it from the Apps menu (developer mode, Update Apps List, search om_account_loan_cash_forecast).
+
+
+
✓
+
The menus are under Invoicing › Accounting, shown to the users with Show Full Accounting Features.