diff --git a/om_account_accountant/static/description/index.html b/om_account_accountant/static/description/index.html index 5e98c525d..2817da525 100644 --- a/om_account_accountant/static/description/index.html +++ b/om_account_accountant/static/description/index.html @@ -4,9 +4,9 @@
Also from Odoo Mates
- Odoo Mates apps for Odoo 20: Automation Studio, HRMS, WhatsApp Meta, WhatsApp Web, WhatsApp QR, Access Manager, Financial Reports -
Automation Studio
Automation StudioFree
Workflows and approvals drawn as a flowchart, on any app.
Odoo 20 HRMS
Odoo 20 HRMSFree
Payroll, loans, exits, approvals and the employee portal.
WhatsApp for Odoo
WhatsApp for OdooFree
Meta Cloud API, WhatsApp Web or a QR code: pick the one you need.
User Access Manager
User Access ManagerFree
Hide menus, fields and buttons, and preview Odoo as the user.
Financial Reports
Financial ReportsPaid
The reports of this app on screen, with drill down and Excel export.
+
Loans and Leases
Loans and LeasesFree
Bank loans and leases: schedule, instalments, lease bills and every entry.
Automation Studio
Automation StudioFree
Workflows and approvals drawn as a flowchart, on any app.
Odoo 20 HRMS
Odoo 20 HRMSFree
Payroll, loans, exits, approvals and the employee portal.
WhatsApp for Odoo
WhatsApp for OdooFree
Meta Cloud API, WhatsApp Web or a QR code: pick the one you need.
User Access Manager
User Access ManagerFree
Hide menus, fields and buttons, and preview Odoo as the user.
Financial Reports
Financial ReportsPaid
The reports of this app on screen, with drill down and Excel export.
See all Odoo Mates apps →
@@ -502,6 +502,30 @@

The recurring templates.

+ +
+ Loans and leasesFree app, new in 20.0 +

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 Assets Management (in this app): the right-of-use asset of each lease, through the bridge Leases: Right-of-use Assets.
+
✓  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
+

The amortization schedule of a loan, in the Loans and Leases app.

+
See Loans and Leases →
+
+
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 + +
+ + + + + + + + + + +
+ Per loan or lease at the date: the outstanding principal (the lease liability), the interest accrued since the last due date, + the next instalment, and the principal due within 12 months and after. +
+
+
+
+
+
+ + + 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 + + + + + + + + + + diff --git a/om_account_loan/security/ir.access.csv b/om_account_loan/security/ir.access.csv new file mode 100644 index 000000000..c97892899 --- /dev/null +++ b/om_account_loan/security/ir.access.csv @@ -0,0 +1,33 @@ +id,name,model_id,group_id/id,operation,domain +access_account_loan_manager,account.loan manager,account.loan,account.group_account_manager,crud, +access_account_loan_user,account.loan accountant,account.loan,account.group_account_user,cru, +access_account_loan_readonly,account.loan readonly,account.loan,account.group_account_readonly,r, +account_loan_comp_rule,Loan multi-company,account.loan,,crud,"[('company_id', 'in', company_ids)]" +access_account_loan_line_manager,account.loan.line manager,account.loan.line,account.group_account_manager,crud, +access_account_loan_line_user,account.loan.line accountant,account.loan.line,account.group_account_user,crud, +access_account_loan_line_readonly,account.loan.line readonly,account.loan.line,account.group_account_readonly,r, +account_loan_line_comp_rule,Loan schedule multi-company,account.loan.line,,crud,"[('company_id', 'in', company_ids)]" +access_account_loan_operation_manager,account.loan.operation manager,account.loan.operation,account.group_account_manager,crud, +access_account_loan_operation_user,account.loan.operation accountant,account.loan.operation,account.group_account_user,cru, +access_account_loan_operation_readonly,account.loan.operation readonly,account.loan.operation,account.group_account_readonly,r, +account_loan_operation_comp_rule,Loan operation multi-company,account.loan.operation,,crud,"[('company_id', 'in', company_ids)]" +access_account_loan_rate_manager,account.loan.rate manager,account.loan.rate,account.group_account_manager,crud, +access_account_loan_rate_user,account.loan.rate accountant,account.loan.rate,account.group_account_user,crud, +access_account_loan_rate_readonly,account.loan.rate readonly,account.loan.rate,account.group_account_readonly,r, +account_loan_rate_comp_rule,Loan rate multi-company,account.loan.rate,,crud,"[('company_id', 'in', company_ids)]" +access_account_loan_drawdown_manager,account.loan.drawdown manager,account.loan.drawdown,account.group_account_manager,cru, +access_account_loan_drawdown_user,account.loan.drawdown accountant,account.loan.drawdown,account.group_account_user,cru, +access_account_loan_pay_manager,account.loan.pay manager,account.loan.pay,account.group_account_manager,cru, +access_account_loan_pay_user,account.loan.pay accountant,account.loan.pay,account.group_account_user,cru, +access_account_loan_repay_manager,account.loan.repay manager,account.loan.repay,account.group_account_manager,cru, +access_account_loan_repay_user,account.loan.repay accountant,account.loan.repay,account.group_account_user,cru, +access_account_loan_reclassify_manager,account.loan.reclassify manager,account.loan.reclassify,account.group_account_manager,cru, +access_account_loan_reclassify_user,account.loan.reclassify accountant,account.loan.reclassify,account.group_account_user,cru, +access_account_loan_report_manager,account.loan.report manager,account.loan.report,account.group_account_manager,crud, +access_account_loan_report_readonly,account.loan.report readonly,account.loan.report,account.group_account_readonly,crud, +access_account_loan_report_line_manager,account.loan.report.line manager,account.loan.report.line,account.group_account_manager,crud, +access_account_loan_report_line_readonly,account.loan.report.line readonly,account.loan.report.line,account.group_account_readonly,crud, +account_loan_report_comp_rule,Loan report multi-company,account.loan.report,,crud,"[('company_id', 'in', company_ids)]" +account_loan_report_line_comp_rule,Loan report line multi-company,account.loan.report.line,,crud,"[('company_id', 'in', company_ids)]" +access_account_loan_import_manager,account.loan.import manager,account.loan.import,account.group_account_manager,cru, +access_account_loan_import_user,account.loan.import accountant,account.loan.import,account.group_account_user,cru, diff --git a/om_account_loan/static/description/banner.gif b/om_account_loan/static/description/banner.gif new file mode 100644 index 000000000..d3917a413 Binary files /dev/null and b/om_account_loan/static/description/banner.gif differ diff --git a/om_account_loan/static/description/icon.png b/om_account_loan/static/description/icon.png new file mode 100644 index 000000000..2e5532396 Binary files /dev/null and b/om_account_loan/static/description/icon.png differ diff --git a/om_account_loan/static/description/index.html b/om_account_loan/static/description/index.html new file mode 100644 index 000000000..e4d6a08c6 --- /dev/null +++ b/om_account_loan/static/description/index.html @@ -0,0 +1,698 @@ +
+
+
Odoo 20 Community · Free · LGPL-3
+

Loans and Leases

+

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 +
+ +
+
+
+
+
2
+
contract types: loans and leases
+
+
+
3
+
schedule methods
+
+
+
4
+
payment frequencies
+
+
+
1
+
dependency: Invoicing
+
+
+
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
Loan100,000.00 at 6% over 12 monthly payments
Instalment8,606.64, the last one 8,606.69
First instalment8,106.64 of principal and 500.00 of interest
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ACTIONJOURNALENTRY
DrawdownBankDr Outstanding Receipts / Cr Long-term loan
InstalmentBankDr Short-term, then Long-term loan (principal) + Dr Interest expense / Cr Outstanding Payments
Early repaymentBankas an instalment, with the interest due to the date
ReclassificationLoan journalDr 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 running loan with its amortization schedule +
+

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 Early Repayment wizard +
+
+The Pay Instalment wizard +
+
+
+
+The operations of a loan: drawdown and early repayment +
+

The Operations tab: the drawdown and the early repayment, with their entries.

+
+The instalments due within 30 days +
+

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 rate6.00% from April 2026
Rate change6.50% from August 1
Rate change6.25% from December 1
Effectnext instalment 3,717.23, then 3,705.98
+
+
+
+A floating-rate loan with its rate changes +
+
+
+
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
Lease12 monthly payments of 1,000.00 at 6%
Lease liability11,618.93 (11,677.03 paid in advance)
First bill941.91 of liability + 58.09 of interest
+
+
+
+A lease with its schedule and the status of its bills +
+

A lease of 36 monthly payments of 2,450.00 at 6%: eight bills paid, two billed, the next one to bill.

+
+The vendor bill of a lease payment +
+

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

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.
+
+
+
+The Reclassification wizard +
+
+
+
+
+
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 on screen +
+

The Loans and Leases report at a date, on screen.

+
+The Loans and Leases report in PDF +
+

The same report in PDF (A4 landscape).

+
+A loan printed with its schedule +
+

A loan printed with its schedule.

+
+Loans Analysis: principal, interest and instalments by year +
+

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:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LOANSLEASES
Menu and numberingAccounting › Loans › Loans; LOAN/2026/0001Accounting › Loans › Leases; LEASE/2026/0001
What you enterThe principalThe lease payment; the lease liability is computed
ScheduleEqual instalments, fixed principal or importedEqual payments
RateFixed or floating, with dated rate changesFixed (implicit rate or incremental borrowing rate)
Money inDrawdowns, in one or more tranchesNone: Dr Right-of-use asset / Cr Lease liability on confirmation
Each paymentPaid from the schedule: a bank entryA vendor bill of the lessor, then paid as any bill
Early repaymentIn part or in full—
Right-of-use asset—On its account, or created and depreciated with Assets Management
Short / long termReclassificationReclassification
Withholding taxOn the payment of the interestOn the interest line of the bill
ReportsLoans and Leases, Loans Analysis, PDFLoans and Leases, Loans Analysis, PDF
Cash forecastUnpaid 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.
+
+
+
+The Loans section of the accounting settings +
+
+
+
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.

+
+
+
Add the bridgeUsing Cash Forecast?
+
If you use Odoo 20 Cash Forecast, add Loans and Leases in the Cash Forecast (om_account_loan_cash_forecast).
+
✓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.
+
+
+
Add the bridgeUsing Assets Management?
+
If you use Odoo 20 Assets Management, add Leases: Right-of-use Assets (om_account_loan_asset).
+
✓The right-of-use asset of each lease, created on its recognition and depreciated over the lease term.
✓Works with the Assets Management you have: nothing to update there.
+
+
+

Neither of them? Loans and Leases works on its own: it needs only Invoicing.

+
+ +
+
Download and install
+

Up and running in five minutes

+

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
+
+
    +
  1. Click Download at the top of this page.
  2. +
  3. Unzip it into a folder of your Odoo addons path.
  4. +
  5. Restart the Odoo server.
  6. +
+
+
+
+
2
+
From GitHub
+
+
    +
  1. Clone the 20.0 branch with the command below, or download it as a zip.
  2. +
  3. Add the folder to the addons path (or copy only om_account_loan and the modules it needs).
  4. +
  5. Restart the server.
  6. +
+
+
+
+
3
+
Install in Odoo
+
+
    +
  1. Turn on the developer mode.
  2. +
  3. Apps › Update Apps List.
  4. +
  5. Remove the Apps filter, search om_account_loan, click Activate.
  6. +
+
+
+
+
The GitHub way, in a terminal:
+
git clone -b 20.0 --depth 1 https://github.com/odoomates/odooapps.git
+
+

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.

+
+
+
+Odoo Mates +
Keeping it simple
+ +
+ + + +
+
+
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 + +
+
+
+ +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 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 +
+
+Loans and Leases +Cash Forecast +
+
+
+
What it adds
+

Two more rows in the 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 with the loan instalments and the lease payments +
+

The cash forecast by month: Loan instalments and Lease payments not yet billed among the payments.

+
+The loan and lease rows detailed by partner +
+

Detail by Partner: each lender and lessor on its own line.

+
+
+
Install
+

Nothing to do, most of the time

+ + + + + + + + + + + + + + + + +
✓It needs Loans and Leases and Odoo 20 Cash Forecast.
✓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.
+

Support: odoomates@gmail.com

+
+
+Odoo Mates +
Keeping it simple
+
Support: odoomates@gmail.com
+
+ + + +
+
+
diff --git a/om_account_loan_cash_forecast/static/description/odoo_mates.png b/om_account_loan_cash_forecast/static/description/odoo_mates.png new file mode 100644 index 000000000..8408fa3a8 Binary files /dev/null and b/om_account_loan_cash_forecast/static/description/odoo_mates.png differ diff --git a/om_account_loan_cash_forecast/tests/__init__.py b/om_account_loan_cash_forecast/tests/__init__.py new file mode 100644 index 000000000..9912dadcf --- /dev/null +++ b/om_account_loan_cash_forecast/tests/__init__.py @@ -0,0 +1 @@ +from . import test_cash_forecast diff --git a/om_account_loan_cash_forecast/tests/test_cash_forecast.py b/om_account_loan_cash_forecast/tests/test_cash_forecast.py new file mode 100644 index 000000000..569672d5d --- /dev/null +++ b/om_account_loan_cash_forecast/tests/test_cash_forecast.py @@ -0,0 +1,43 @@ +from datetime import date + +from odoo import Command +from odoo.tests import tagged + +from odoo.addons.om_account_loan.tests.common import LoanCommon + + +@tagged('post_install', '-at_install') +class TestLoanCashForecast(LoanCommon): + + def _forecast(self): + wizard = self.env['account.cash.forecast.report'].create({ + 'date_from': date(2025, 1, 1), 'period_type': 'month', 'period_count': 4, 'overdue': 'exclude'}) + forecast = self.env['report.om_account_cash_forecast.report_cash_forecast']._get_forecast(wizard) + return {row['key']: row['amounts'] for section in forecast['sections'] for row in section['rows']} + + def test_instalments_expected(self): + """ The unpaid instalments are payments of the forecast on their due dates, net of the tax withheld. """ + loan = self._create_loan(withholding_tax_id=self.withholding_tax.id) + self._draw(loan) + lines = self._lines(loan) + self._pay(lines[0]) + rows = self._forecast() + # January, February (paid), March, April + self.assertEqual(rows['loans_out'], [0.0, 0.0, -lines[1].net_amount, -lines[2].net_amount]) + + def test_lease_payments_not_billed(self): + """ The lease payments not billed yet are expected with the taxes of their bills; a bill created is a + vendor bill of the forecast. """ + lease = self._create_lease(bill_tax_ids=[Command.set(self.vat_tax.ids)]) + lines = self._lines(lease) + lines[0]._create_bill() + rows = self._forecast() + report = self.env['report.om_account_cash_forecast.report_cash_forecast'] + expected = [-report._lease_payment_with_taxes(line) for line in lines[1:3]] + self.assertEqual(rows['leases_out'], [0.0, 0.0, *expected]) + self.assertAlmostEqual(expected[0], -1070.0, places=2) + self.assertNotIn('loans_out', rows) + + def test_draft_loans_left_out(self): + self._create_loan(confirm=False) + self.assertNotIn('loans_out', self._forecast())