diff --git a/account_move_cutoff/README.rst b/account_move_cutoff/README.rst new file mode 100644 index 00000000000..ecc13c41b3d --- /dev/null +++ b/account_move_cutoff/README.rst @@ -0,0 +1,193 @@ +==================== +Account Move Cut-off +==================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--closing-lightgray.png?logo=github + :target: https://github.com/OCA/account-closing/tree/14.0/account_move_cutoff + :alt: OCA/account-closing +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/account-closing-14-0/account-closing-14-0-account_move_cutoff + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/89/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to generate cu-toff entries automatically when posting former +entries. + +This module is based on `account_invoice_start_end_dates` +which allows to define start end end dates on invoice line (`account.move.line`). + + +Following assumption have been made before developing this module:: + + - New method to compute cutoff amounts can be add by business modules + + +.. note:: + + This module as been developed with some opinionated design + do not depends on `account_cutoff_base`. Because:: + + - we don't want rely on user nor async task at the end of + each month (period) + - link entries to understand the history without merging amounts + in order to be able to keep details on deferred account + move line (analytics, partners, accounts and so on) + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Deferred journal(s) +~~~~~~~~~~~~~~~~~~~ + +In accounting configuration you should set +Deferred Revenue and Expense journal to be used +on generated entries. + +.. note:: + + Journal will be used according the kind of + journal used by the former entry: `sale` or `purchase` + + +Deferred account(s) +~~~~~~~~~~~~~~~~~~~ + +On each Revenue/Expense account you can set the deferred +Revenue/Expense account. + +Only invoice lines linked to account with a deferred account set +will generate deferred Revenue/Expense. + + +Cut-off Method +~~~~~~~~~~~~~~ + +In the first version of this module, two cut-off computation methods are +supported and can be configured using the ``account_move_cutoff.default_cutoff_method`` +key. The currently possible values are ``monthly_prorata_temporis`` or ``equal``. + +Before defining these values, let's provide some context by using an example to +illustrate the definitions. Consider a sales invoice that is posted on January +16th for a service that spans from the 8th to the 15th of March. So, there are +24 days in January, 1 full month in February, and 15 days in March. The product +is sold for 1000 for a month, so the invoice line amount (excluding VAT) is +calculated as follows:: + + 24/31 * 1000 + 1000 + 15/31 * 1000 = 2258.06 + +* **monthly_prorata_temporis** (the default if not set): This method splits amounts + over the rate of the month the product has been used. The results would be as + follows: + + - January: **774.19** (`2258.06 - 1000 - 483.87`) (Subtraction is used here to avoid + rounding discrepancies.) + - February: **1000.00** (`1 * 2258.06 / (24/31 + 1 + 15/31)`) + - March: **483.87** (`15/31 * 2258.06 / (24/31 + 1 + 15/31)`) + +* **equal**: With this method, the same amount is split over the months of service. + + - January: **752.68** (`2258.06 - 752.69 - 752.69`) + - February: **752.69** (2258.06 / 3) + - March: **752.69** (2258.06 / 3) + +Please note that this information is subject to change based on updates to the +module. Always refer to the latest documentation for accurate details. + +Usage +===== + + +To handle deferred accounting, follow these steps: + +1. Set the start and end date, where the end date is at least set to + the month after the current entry posted date. + +2. Ensure that the account (the `account.account` configuration) + in use is linked to a deferred account. + +3. Post the entry. + +4. After posting, check deferred entries have been generated, posted, and + reconciled if needed. + +.. note:: + + This module only defers amounts in periods subsequent to the accounting period + date. For example, if an invoice is posted on March 2nd for a service from + January 1st to March 31st at 1000€ per month, the deferred amount will be + only 1000€ for March, leaving 2000€ in February. We never add accounting items + in periods previous to the current entry date. + +Known issues / Roadmap +====================== + +- Make the is_deferrable_line a storable field to allow end user to not + deferred a given line while posting entry. (but should raise if + it's not possible to force the value to true) +- allow to change/configure cutoff frequency (weekly/monthly/...) + today only monthly is implemented +- allow to configure cutoff computation method in different + place (product / invoice lines /...) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Pierre Verkest + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-petrus-v| image:: https://github.com/petrus-v.png?size=40px + :target: https://github.com/petrus-v + :alt: petrus-v + +Current `maintainer `__: + +|maintainer-petrus-v| + +This module is part of the `OCA/account-closing `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_move_cutoff/__init__.py b/account_move_cutoff/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_move_cutoff/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_move_cutoff/__manifest__.py b/account_move_cutoff/__manifest__.py new file mode 100644 index 00000000000..a1f7edf9411 --- /dev/null +++ b/account_move_cutoff/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2022 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Move Cut-off", + "version": "14.0.0.0.1", + "category": "Accounting & Finance", + "license": "AGPL-3", + "summary": "Account move Cut-offs, manage Deferred Revenues/Expenses", + "author": "Pierre Verkest , Odoo Community Association (OCA)", + "maintainers": ["petrus-v"], + "website": "https://github.com/OCA/account-closing", + "depends": ["account", "account_invoice_start_end_dates"], + "data": [ + "views/account_account.xml", + "views/account_move_line.xml", + "views/account_move.xml", + "views/res_config_settings.xml", + ], + "installable": True, +} diff --git a/account_move_cutoff/i18n/account_move_cutoff.pot b/account_move_cutoff/i18n/account_move_cutoff.pot new file mode 100644 index 00000000000..71ba11bf1e6 --- /dev/null +++ b/account_move_cutoff/i18n/account_move_cutoff.pot @@ -0,0 +1,294 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_move_cutoff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Cutoff journal" +msgstr "" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.view_move_form +msgid "Deffered Revenue/Expense" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_account +msgid "Account" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_account__deferred_accrual_account_id +msgid "" +"Account used to deferred Revenues/Expenses in next periods. If not set " +"revenue won't be deferred" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Adjust deferred incomes of %s (%s): %s" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Adjusting Entry: %s (%s): %s" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance adjustment of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance expense adjustment of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance expense recognition of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance recognition of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance revenue adjustment of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance revenue recognition of %s (%s)" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_res_company +msgid "Companies" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__expense_cutoff_journal_id +msgid "Cut-off Expense Journal" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__revenue_cutoff_journal_id +msgid "Cut-off Revenue Journal" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_entry_ids +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_entry_ids +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_entry_ids +msgid "Cut-off entries" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_ids +msgid "Cut-off items" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_method +msgid "Cut-off method" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_move_count +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_move_count +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_move_count +msgid "Cutoff Move Count" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_from_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_from_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_source_move_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_from_id +msgid "Cutt-off source entry" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_source_id +msgid "Cutt-off source item" +msgstr "" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.view_move_line_form +msgid "Deferred Revenue/Expense" +msgstr "" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Deferred incomes of %s (%s): %s" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_method +msgid "" +"Determine how to split amounts over periods:\n" +" * Equal: same amount is splitted over periods of the service (using start and end date on the invoice line).\n" +" * Prorata temporis by month %: amount is splitted over the rate of service days in the month.\n" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__display_name +msgid "Display Name" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields.selection,name:account_move_cutoff.selection__account_move_line__cutoff_method__equal +msgid "Equal" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__expense_cutoff_journal_id +msgid "Expense cut-off journal" +msgstr "" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Expenses" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_bank_statement_line__cutoff_entry_ids +#: model:ir.model.fields,help:account_move_cutoff.field_account_move__cutoff_entry_ids +#: model:ir.model.fields,help:account_move_cutoff.field_account_payment__cutoff_entry_ids +msgid "" +"Field use to make easy to user to follow entries generated from this " +"specific entry to deferred revenues or expenses." +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_ids +msgid "" +"Field use to make easy to user to follow items generated from this specific " +"entry to deferred revenues or expenses." +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__is_deferrable_line +msgid "Field used to detect lines to cut-off" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__is_deferrable_line +msgid "Is deferrable line" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_move_line +msgid "Journal Item" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings____last_update +msgid "Last Modified on" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields.selection,name:account_move_cutoff.selection__account_move_line__cutoff_method__monthly_prorata_temporis +msgid "Prorata temporis (by month %)" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__revenue_cutoff_journal_id +msgid "Revenue cut-off journal" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__deferred_accrual_account_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__deferred_accrual_account_id +msgid "Revenue/Expense accrual account" +msgstr "" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Revenues" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_bank_statement_line__cutoff_from_id +#: model:ir.model.fields,help:account_move_cutoff.field_account_move__cutoff_from_id +#: model:ir.model.fields,help:account_move_cutoff.field_account_payment__cutoff_from_id +msgid "Source entry that generate the current deferred revenue/expense entry" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_source_id +msgid "" +"Source journal item that generate the current deferred revenue/expense item" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_source_move_id +msgid "The move of this entry line." +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__deferred_accrual_account_id +msgid "" +"Use related field to abstract the way to get deferred accrual account. This " +"will give the possibility to overwrite the way to configure it. For instance" +" to use the same account without any configuration while creating new " +"account." +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_cutoff_period_mixin +msgid "Utilities method related to cuttoff mixins" +msgstr "" diff --git a/account_move_cutoff/i18n/fr.po b/account_move_cutoff/i18n/fr.po new file mode 100644 index 00000000000..56aba452bee --- /dev/null +++ b/account_move_cutoff/i18n/fr.po @@ -0,0 +1,319 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_move_cutoff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Cutoff journal" +msgstr "" +"Journaux des Produits/Charges constatés " +"d'avance" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.view_move_form +msgid "Deffered Revenue/Expense" +msgstr "Produits/Charges constaté d'avance" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_account +msgid "Account" +msgstr "Compte" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_account__deferred_accrual_account_id +msgid "" +"Account used to deferred Revenues/Expenses in next periods. If not set " +"revenue won't be deferred" +msgstr "" +"Compte utilisé pour constater des produits ou des charges d'avances. Si " +"aucun compte définit il n'y aura pas de charge constaté d'avance sur ce " +"compte." + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Adjust deferred incomes of %s (%s): %s" +msgstr "Régularisation des revenus/charges constaté d'avance de : %s (%s): %s" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Adjusting Entry: %s (%s): %s" +msgstr "Régularisation de charge/produit constaté d'avance de : %s (%s): %s" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance adjustment of %s (%s)" +msgstr "Régularisation de charge/produit constaté d'avance de : %s (%s)" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance expense adjustment of %s (%s)" +msgstr "Régularisation de charge constaté d'avance de %s (%s)" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance expense recognition of %s (%s)" +msgstr "Charge constaté d'avance de %s (%s)" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance recognition of %s (%s)" +msgstr "Produit/Charge constaté d'avance de %s (%s)" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance revenue adjustment of %s (%s)" +msgstr "Régularisation de produit constaté d'avance" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move.py:0 +#, python-format +msgid "Advance revenue recognition of %s (%s)" +msgstr "Produit constaté d'avance de %s (%s)" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_res_config_settings +msgid "Config Settings" +msgstr "Configuration" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__expense_cutoff_journal_id +msgid "Cut-off Expense Journal" +msgstr "Journal des charges constatées d'avances" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__revenue_cutoff_journal_id +msgid "Cut-off Revenue Journal" +msgstr "Journal des produits constatés d'avances" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_entry_ids +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_entry_ids +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_entry_ids +msgid "Cut-off entries" +msgstr "Pièces comptable PCA/CCA" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_ids +msgid "Cut-off items" +msgstr "Ecriture de PCA/CCA" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_method +msgid "Cut-off method" +msgstr "Méthode de PCA/CCA" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_move_count +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_move_count +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_move_count +msgid "Cutoff Move Count" +msgstr "PCA/CCA count" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_bank_statement_line__cutoff_from_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__cutoff_from_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_source_move_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_payment__cutoff_from_id +msgid "Cutt-off source entry" +msgstr "PCA/CCA: Pièce comptable initiale" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__cutoff_source_id +msgid "Cutt-off source item" +msgstr "PCA/CCA: Ecriture initiale" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.view_move_line_form +msgid "Deferred Revenue/Expense" +msgstr "PCA/CCA" + +#. module: account_move_cutoff +#: code:addons/account_move_cutoff/models/account_move_line.py:0 +#, python-format +msgid "Deferred incomes of %s (%s): %s" +msgstr "PCA/CCA de %s (%s): %s" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_method +msgid "" +"Determine how to split amounts over periods:\n" +" * Equal: same amount is splitted over periods of the service (using start " +"and end date on the invoice line).\n" +" * Prorata temporis by month %: amount is splitted over the rate of " +"service days in the month.\n" +msgstr "" +"Méthode de calcul utilisé pour répartir les montant par période (mensuel) " +"fiscale :\n" +" * Equal: même valeur sur l'ensemble des mois de service\n" +" * Prorata-temporis par % de mois: mle montant est réparti en fonction du " +"taux de service de jour dans le mois" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__display_name +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__display_name +msgid "Display Name" +msgstr "Libellé" + +#. module: account_move_cutoff +#: model:ir.model.fields.selection,name:account_move_cutoff.selection__account_move_line__cutoff_method__equal +msgid "Equal" +msgstr "Égale" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__expense_cutoff_journal_id +msgid "Expense cut-off journal" +msgstr "Journal des charges constaté d'avance CCA" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Expenses" +msgstr "Charges" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_bank_statement_line__cutoff_entry_ids +#: model:ir.model.fields,help:account_move_cutoff.field_account_move__cutoff_entry_ids +#: model:ir.model.fields,help:account_move_cutoff.field_account_payment__cutoff_entry_ids +msgid "" +"Field use to make easy to user to follow entries generated from this " +"specific entry to deferred revenues or expenses." +msgstr "" +"Champ utilisé pour faciliter la compréhension et retrouver l'orignie des " +"pièces comptables générées dans le cadres des PCA/CCA." + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_ids +msgid "" +"Field use to make easy to user to follow items generated from this specific " +"entry to deferred revenues or expenses." +msgstr "" +"Champ utilisé pour faciliter la compréhension et retrouver l'orignie des " +"pièces comptables générées dans le cadres des PCA/CCA." + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__is_deferrable_line +msgid "Field used to detect lines to cut-off" +msgstr "" +"Permet de déterminer si il est nécessaire de faire des PCA/CCA sur " +"l'écriture." + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company__id +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__is_deferrable_line +msgid "Is deferrable line" +msgstr "Ligne générant des PCA/CCA" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_move +msgid "Journal Entry" +msgstr "Pièce comptable" + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_account_move_line +msgid "Journal Item" +msgstr "Ecriture comptable" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_cutoff_period_mixin____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_company____last_update +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: account_move_cutoff +#: model:ir.model.fields.selection,name:account_move_cutoff.selection__account_move_line__cutoff_method__monthly_prorata_temporis +msgid "Prorata temporis (by month %)" +msgstr "Prorata temporis (par % mois)" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_res_config_settings__revenue_cutoff_journal_id +msgid "Revenue cut-off journal" +msgstr "Journal de produit constaté d'avance" + +#. module: account_move_cutoff +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_account__deferred_accrual_account_id +#: model:ir.model.fields,field_description:account_move_cutoff.field_account_move_line__deferred_accrual_account_id +msgid "Revenue/Expense accrual account" +msgstr "Compte de PCA/CCA" + +#. module: account_move_cutoff +#: model_terms:ir.ui.view,arch_db:account_move_cutoff.res_config_settings_view_form +msgid "Revenues" +msgstr "Produits" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_bank_statement_line__cutoff_from_id +#: model:ir.model.fields,help:account_move_cutoff.field_account_move__cutoff_from_id +#: model:ir.model.fields,help:account_move_cutoff.field_account_payment__cutoff_from_id +msgid "Source entry that generate the current deferred revenue/expense entry" +msgstr "" +"Pièce comptable source à l'origine de la génération de la pièce compte de " +"PCA/CCA actuel." + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_source_id +msgid "" +"Source journal item that generate the current deferred revenue/expense item" +msgstr "" +"Ecriture d'origine à la base de la génération de l'écriture comptable actuel." + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__cutoff_source_move_id +msgid "The move of this entry line." +msgstr "La pièce comptable d'origin à cette écirture comptable" + +#. module: account_move_cutoff +#: model:ir.model.fields,help:account_move_cutoff.field_account_move_line__deferred_accrual_account_id +msgid "" +"Use related field to abstract the way to get deferred accrual account. This " +"will give the possibility to overwrite the way to configure it. For instance " +"to use the same account without any configuration while creating new account." +msgstr "" +"Utilise un champ lié pour rendre abstrait la méthode de récupération du compte de " +"cut-off et ainsi faciliter la surcharge par un autre module. Par exemple on pourrait " +"forcer l'usage d'un compte en particulier et ainsi éviter la configuration des comptes " +"de produit et de charge individuellement." + +#. module: account_move_cutoff +#: model:ir.model,name:account_move_cutoff.model_cutoff_period_mixin +msgid "Utilities method related to cuttoff mixins" +msgstr "Mixin avec des méthode facilitant la gestion des périodes" diff --git a/account_move_cutoff/models/__init__.py b/account_move_cutoff/models/__init__.py new file mode 100644 index 00000000000..1a24851791b --- /dev/null +++ b/account_move_cutoff/models/__init__.py @@ -0,0 +1,6 @@ +from . import cutoff_period_mixin +from . import account_account +from . import account_move +from . import account_move_line +from . import res_company +from . import res_config_settings diff --git a/account_move_cutoff/models/account_account.py b/account_move_cutoff/models/account_account.py new file mode 100644 index 00000000000..4f74f321812 --- /dev/null +++ b/account_move_cutoff/models/account_account.py @@ -0,0 +1,20 @@ +# Copyright 2023 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class Account(models.Model): + _inherit = "account.account" + + deferred_accrual_account_id = fields.Many2one( + comodel_name="account.account", + string="Revenue/Expense accrual account", + domain="[('company_id', '=', company_id)," + "('internal_type', 'not in', ('receivable', 'payable'))," + "('is_off_balance', '=', False)]", + help=( + "Account used to deferred Revenues/Expenses in next periods. " + "If not set revenue won't be deferred" + ), + ) diff --git a/account_move_cutoff/models/account_move.py b/account_move_cutoff/models/account_move.py new file mode 100644 index 00000000000..edcd395ccbb --- /dev/null +++ b/account_move_cutoff/models/account_move.py @@ -0,0 +1,217 @@ +# Copyright 2023 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class AccountMove(models.Model): + _name = "account.move" + _inherit = ["account.move", "cutoff.period.mixin"] + + cutoff_from_id = fields.Many2one( + comodel_name="account.move", + string="Cutt-off source entry", + help="Source entry that generate the current deferred revenue/expense entry", + ) + cutoff_move_count = fields.Integer(compute="_compute_cutoff_move_count") + + cutoff_entry_ids = fields.One2many( + comodel_name="account.move", + inverse_name="cutoff_from_id", + string="Cut-off entries", + readonly=True, + help=( + "Field use to make easy to user to follow entries generated " + "from this specific entry to deferred revenues or expenses." + ), + ) + + def _compute_cutoff_move_count(self): + for rec in self: + rec.cutoff_move_count = len(rec.cutoff_entry_ids) + + def button_draft(self): + res = super().button_draft() + # it's probably a bit ugly, for time being I prefer to unlink/create + # to maintain consistency cutoff_entry_ids shouldn't be on sale/purchase + # journals + self.cutoff_entry_ids.line_ids.remove_move_reconcile() + # force delete because we shouldn't remove entries that has been posted + self.cutoff_entry_ids.with_context(force_delete=True).unlink() + return res + + def action_post(self): + result = super().action_post() + for move, lines in self._get_deferrable_lines(): + move._create_cutoff_entries(lines) + + return result + + def _get_deferrable_lines(self): + """Return line to deferred revenues/expenses group by move""" + return ( + self.filtered(lambda account_move: account_move.move_type != "entry") + .line_ids.filtered(lambda line: line.is_deferrable_line) + .group_recordset_by(lambda move_line: move_line.move_id) + ) + + def _get_deferred_periods(self, lines): + """Generate periods concerned by given lines from min and max date + + This implementation consider a month as period, each period + is represented by a datetime.date: the first day of the month + """ + self.ensure_one() + first_date = self.date + last_date = max([first_date] + lines.mapped("end_date")) + return self._generate_monthly_periods(first_date, last_date) + + def _get_deferred_date_from_period(self, period): + # as today we only support monthly period represented by + # the first date + return period + + def _get_deferred_journal(self): + self.ensure_one() + # At the moment we handle only deferred entries from + # Sale and Purchase journal + journal = self.env["account.journal"].browse() + if self.journal_id.type == "sale": + journal = self.company_id.revenue_cutoff_journal_id + elif self.journal_id.type == "purchase": + journal = self.company_id.expense_cutoff_journal_id + return journal + + def _get_deferred_titles(self): + self.ensure_one() + cutoff_title = _("Advance recognition of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + deferred_title = _("Advance adjustment of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + if self.journal_id.type == "sale": + cutoff_title = _("Advance revenue recognition of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + deferred_title = _("Advance revenue adjustment of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + elif self.journal_id.type == "purchase": + cutoff_title = _("Advance expense recognition of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + deferred_title = _("Advance expense adjustment of %s (%s)") % ( + self.name, + self.date.strftime("%m %Y"), + ) + return cutoff_title, deferred_title + + def _prepare_deferred_entry(self, journal, date_, reference): + return { + "currency_id": journal.currency_id.id or journal.company_id.currency_id.id, + "move_type": "entry", + "line_ids": [], + "ref": reference, + "date": date_, + "journal_id": journal.id, + "partner_id": self.partner_id.id, + "cutoff_from_id": self.id, + } + + def _create_cutoff_entries(self, lines_to_deferred): + # create one entry to move to the deferred_accrual_account_id + self.ensure_one() + + entries = self.env["account.move"] + journal = self._get_deferred_journal() + # if not journal returns means no deferred entries to + # generate, we don't want any raises here + if not journal: + return + + cutoff_title, deferred_title = self._get_deferred_titles() + periods = self._get_deferred_periods(lines_to_deferred) + cutoff_entry = self.create( + self._prepare_deferred_entry( + journal, fields.Date.to_string(self.date), cutoff_title + ) + ) + entries |= cutoff_entry + + amounts = self._get_amounts_by_period(lines_to_deferred, periods) + # manage cutoff entry + for line, amounts_by_period in amounts: + line._create_cutoff_entry_lines( + cutoff_entry, + periods[0], + sum([amounts_by_period.get(p, 0) for p in periods[1:]]), + ) + + # manage deferred entries + for period in periods[1:]: + deferred_entry = self.create( + self._prepare_deferred_entry( + journal, + fields.Date.to_string(self._get_deferred_date_from_period(period)), + deferred_title, + ) + ) + for line, amounts_by_period in amounts: + if amounts_by_period.get(period, 0) == 0: + continue + + line._create_deferred_entry_lines( + deferred_entry, period, amounts_by_period[period] + ) + + if deferred_entry.line_ids: + entries |= deferred_entry + else: + # TODO: not sure if the case is possible + # if so not sure it's good idea to create and unlink + # in such transaction + deferred_entry.unlink() + + entries.action_post() + for _account, lines in entries.line_ids.filtered( + lambda line: line.account_id.reconcile + and not line.currency_id.is_zero(line.balance) + ).group_recordset_by(lambda line: line.account_id): + lines.reconcile() + + def _get_amounts_by_period(self, lines_to_deferred, periods): + """return data with amount to dispatched per account move line and per periods:: + + [ + ( + line1, # record of the original account.move.line, + { # dict of amounts per period + period1: amount1, + period2: amount2, + } + ), + ( + line2, + {...} + ) + ] + + Developer should take care of rounding issues. + + amount on the first period is the amount that won't be deferred + """ + return lines_to_deferred._get_deferred_amounts_by_period(periods) + + def action_view_deferred_entries(self): + self.ensure_one() + xmlid = "account.action_move_journal_line" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["domain"] = [("id", "in", self.cutoff_entry_ids.ids)] + return action diff --git a/account_move_cutoff/models/account_move_line.py b/account_move_cutoff/models/account_move_line.py new file mode 100644 index 00000000000..97c0afdc81b --- /dev/null +++ b/account_move_cutoff/models/account_move_line.py @@ -0,0 +1,315 @@ +# Copyright 2023 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +from collections import defaultdict + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models + +logger = logging.getLogger(__name__) + + +class AccountMoveLine(models.Model): + _name = "account.move.line" + _inherit = [ + "account.move.line", + "cutoff.period.mixin", + ] + + @api.model + def _get_default_cutoff_method(self): + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param( + "account_move_cutoff.default_cutoff_method", "monthly_prorata_temporis" + ) + ) + + is_deferrable_line = fields.Boolean( + string="Is deferrable line", + compute="_compute_is_deferrable_line", + help=("Field used to detect lines to cut-off"), + ) + cutoff_method = fields.Selection( + [ + ("equal", "Equal"), + ("monthly_prorata_temporis", "Prorata temporis (by month %)"), + ], + string="Cut-off method", + required=True, + default=lambda self: self._get_default_cutoff_method(), + help=( + "Determine how to split amounts over periods:\n" + " * Equal: same amount is splitted over periods of the service" + " (using start and end date on the invoice line).\n" + " * Prorata temporis by month %: amount is splitted over" + " the rate of service days in the month.\n" + ), + ) + deferred_accrual_account_id = fields.Many2one( + comodel_name="account.account", + string="Revenue/Expense accrual account", + related="account_id.deferred_accrual_account_id", + help=( + "Use related field to abstract the way to get deferred accrual account. " + "This will give the possibility to overwrite the way to configure it. " + "For instance to use the same account without any configuration while " + "creating new account." + ), + ) + + cutoff_source_id = fields.Many2one( + comodel_name="account.move.line", + string="Cutt-off source item", + readonly=True, + help="Source journal item that generate the current deferred revenue/expense item", + ) + cutoff_source_move_id = fields.Many2one( + comodel_name="account.move", + string="Cutt-off source entry", + related="cutoff_source_id.move_id", + readonly=True, + store=True, + ) + cutoff_ids = fields.One2many( + comodel_name="account.move.line", + inverse_name="cutoff_source_id", + string="Cut-off items", + readonly=True, + help=( + "Field use to make easy to user to follow items generated " + "from this specific entry to deferred revenues or expenses." + ), + ) + + @api.depends( + "move_id.date", + "account_id", + "deferred_accrual_account_id", + "start_date", + "end_date", + ) + def _compute_is_deferrable_line(self): + for move_line in self: + if ( + move_line.start_date + and move_line.end_date + and move_line.move_id.date + and move_line.deferred_accrual_account_id + and not move_line.cutoff_ids + and move_line.has_deferred_dates() + ): + move_line.is_deferrable_line = True + else: + move_line.is_deferrable_line = False + + def has_deferred_dates(self): + """Compute the account move line should be split: + has service in future periods + """ + self.ensure_one() + if self._period_from_date(self.end_date) > self._period_from_date( + self.move_id.date + ): + return True + return False + + @api.model + def _get_first_day_from_period(self, period): + return period + + @api.model + def _get_last_day_from_period(self, period): + return self._last_day_of_month(period) + + @api.model + def _line_amounts_on_proper_periods(self, line_amounts, periods): + extra_amount_first_period = 0 + extra_amount_last_period = 0 + amounts = defaultdict(lambda: 0) + date_first_period = self._get_first_day_from_period(periods[0]) + date_end_period = self._get_last_day_from_period(periods[-1]) + for period, line_period_amount in line_amounts.items(): + if self._get_last_day_from_period(period) < date_first_period: + extra_amount_first_period += line_period_amount + continue + if self._get_first_day_from_period(period) > date_end_period: + extra_amount_last_period += line_period_amount + continue + amounts[period] = line_period_amount + amounts[periods[0]] += extra_amount_first_period + amounts[periods[-1]] += extra_amount_last_period + return amounts + + def _round_amounts(self, line_periods): + """This method is used to round values and avoid + rounding side effects + """ + amounts = {} + sum_from_next_periods = 0 + first_period = None + for index, item in enumerate(line_periods.items()): + period, amount = item + amounts[period] = self.currency_id.round(amount) + if index > 0: + sum_from_next_periods += amounts[period] + else: + first_period = period + # avoid rounding difference + if first_period: + amounts[first_period] = self.currency_id.round( + abs(self.balance) - sum_from_next_periods + ) + return amounts + + def _get_amounts_per_periods(self, periods): + """dispatch to the proper method""" + self.ensure_one() + line_amounts = {} + if self.cutoff_method == "equal": + line_amounts = self._get_amounts_per_periods_equal() + else: # monthly_prorata_temporis + line_amounts = self._get_amounts_per_periods_monthly_prorata_temporis() + line_amounts = self._line_amounts_on_proper_periods(line_amounts, periods) + line_amounts = self._round_amounts(line_amounts) + return line_amounts + + def _get_amounts_per_periods_equal(self): + self.ensure_one() + amounts = dict() + line_periods = self._generate_monthly_periods(self.start_date, self.end_date) + for period in line_periods: + amounts[period] = abs(self.balance) / len(line_periods) + return amounts + + def _get_amounts_per_periods_monthly_prorata_temporis(self): + self.ensure_one() + amounts = dict() + line_periods = self._generate_monthly_periods(self.start_date, self.end_date) + line_periods_factors = dict.fromkeys(line_periods, 1) + last_day_first_period = self._last_day_of_month(line_periods[0]) + # use of +1 because service days includes start and end dates [start_date, end_date] + line_periods_factors[line_periods[0]] = ( + (last_day_first_period - self.start_date).days + 1 + ) / last_day_first_period.day + line_periods_factors[line_periods[-1]] = ( + self.end_date.day / self._last_day_of_month(line_periods[-1]).day + ) + + sum_factor = sum([factor for _period, factor in line_periods_factors.items()]) + for period, factor in line_periods_factors.items(): + amounts[period] = abs(self.balance) * factor / sum_factor + + return amounts + + def _get_deferred_amounts_by_period(self, periods): + amounts_per_line_and_periods = [] + for line in self: + amounts_per_line_and_periods.append( + (line, line._get_amounts_per_periods(periods)) + ) + return amounts_per_line_and_periods + + def _get_period_start_end_dates(self, period): + last_period_day = self._last_day_of_month(period) + if self.start_date > last_period_day or self.end_date < period: + start_date = None + end_date = None + else: + start_date = max(self.start_date, period) + end_date = min(self.end_date, self._last_day_of_month(period)) + return start_date, end_date + + def _get_deferred_expense_revenue_account_move_line_labels(self, is_cutoff=None): + if is_cutoff: + return _("Deferred incomes of %s (%s): %s") % ( + self.move_id.name, + self.date.strftime("%m %Y"), + self.name, + ) + else: + return _("Adjust deferred incomes of %s (%s): %s") % ( + self.move_id.name, + self.date.strftime("%m %Y"), + self.name, + ) + + def _prepare_entry_lines(self, new_move, period, amount, is_cutoff=True): + self.ensure_one() + if amount == 0: + return self.env["account.move.line"].browse() + # deferred_line = self.copy() + # deferred_line.account_id = self.deferred_accrual_account_id + # deferred_line.tax_ids = False + # revenue_or_expense_line = self.copy() + reported_credit = reported_debit = 0 + if self.credit > 0: + reported_credit = amount + reported_debit = 0 + else: + reported_debit = amount + reported_credit = 0 + + if is_cutoff: + start_date = max(period + relativedelta(months=1, day=1), self.start_date) + end_date = self.end_date + else: + start_date, end_date = self._get_period_start_end_dates(period) + return self.env["account.move.line"].create( + [ + { + "move_id": new_move.id, + "name": self._get_deferred_expense_revenue_account_move_line_labels( + is_cutoff=is_cutoff + ), + "start_date": start_date, + "end_date": end_date, + "debit": reported_credit if is_cutoff else reported_debit, + "credit": reported_debit if is_cutoff else reported_credit, + "currency_id": self.currency_id.id, + "account_id": self.account_id.id, + "partner_id": self.partner_id.id, + "analytic_account_id": self.analytic_account_id.id, + "cutoff_source_id": self.id, + }, + { + "move_id": new_move.id, + "name": _("Adjusting Entry: %s (%s): %s") + % ( + self.move_id.name, + self.date.strftime("%m %Y"), + self.name, + ), + "start_date": start_date, + "end_date": end_date, + "debit": reported_debit if is_cutoff else reported_credit, + "credit": reported_credit if is_cutoff else reported_debit, + "currency_id": self.currency_id.id, + "account_id": self.deferred_accrual_account_id.id, + "partner_id": self.partner_id.id, + "analytic_account_id": False, + "cutoff_source_id": self.id, + }, + ] + ) + + def _create_cutoff_entry_lines(self, new_move, period, amount): + """Return record set with new journal items (account.move.line) + to link to the cuttoff entry for the current invoice line, with the + amount defined here. + + We are not deferring VAT ! + """ + return self._prepare_entry_lines(new_move, period, amount) + + def _create_deferred_entry_lines(self, new_move, period, amount): + """Return record set with new journal items (account.move.line) + to link to the deferred entry for the current invoice line, with the + amount defined here. + + We are not deferring VAT ! + """ + return self._prepare_entry_lines(new_move, period, amount, is_cutoff=False) diff --git a/account_move_cutoff/models/cutoff_period_mixin.py b/account_move_cutoff/models/cutoff_period_mixin.py new file mode 100644 index 00000000000..029a9da752a --- /dev/null +++ b/account_move_cutoff/models/cutoff_period_mixin.py @@ -0,0 +1,78 @@ +# Copyright 2023 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from collections import defaultdict +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import api, models + + +class CutoffPeriodMixin(models.AbstractModel): + _name = "cutoff.period.mixin" + _description = "Utilities method related to cuttoff mixins" + + @api.model + def _first_day_of_month(self, date_): + """Return the first day of the month for the given date or datetime + returned as date + """ + if isinstance(date_, datetime): + date_ = date_.date() + return date_ + relativedelta(day=1) + + @api.model + def _period_from_date(self, date_): + return self._first_day_of_month(date_) + + @api.model + def _last_day_of_month(self, date_): + """Return the last day of the month for the given date or datetime + as date + """ + # * add 1 month (datetime util return the last day of the next month + # in case date does not exists) + # * get the first of the month + # * then get the day before + if isinstance(date_, datetime): + date_ = date_.date() + return date_ + relativedelta(months=1, day=1, days=-1) + + @api.model + def _generate_monthly_periods(self, date_from, date_to): + """Return a list of period. The first day of each month + between date_from and date_to including the months of + date_from and date_to. + """ + periods = [] + current_period = self._period_from_date(date_from) + while current_period <= date_to: + periods.append(current_period) + current_period += relativedelta(months=1) + + return periods + + def group_recordset_by(self, key): + """Return a collection of pairs ``(key, recordset)`` from ``self``. The + ``key`` is a function computing a key value for each element. This + function is similar to ``itertools.groupby``, but aggregates all + elements under the same key, not only consecutive elements. + + it's also similar to ``òdoo.tools.misc.groupby`` but return a recordset + of account.move.line instead empty list + + this let write some code likes this:: + + my_recordset.filtered( + lambda record: record.to_use + ).group_recordset_by( + lambda record: record.type + ) + + # TODO: consider moving this method on odoo.models.Model + """ + groups = defaultdict(self.env[self._name].browse) + for elem in self: + groups[key(elem)] |= elem + return groups.items() diff --git a/account_move_cutoff/models/res_company.py b/account_move_cutoff/models/res_company.py new file mode 100644 index 00000000000..27f93b3fee3 --- /dev/null +++ b/account_move_cutoff/models/res_company.py @@ -0,0 +1,20 @@ +# Copyright 2023 Foodles (http://www.foodles.co). +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + revenue_cutoff_journal_id = fields.Many2one( + "account.journal", + string="Cut-off Revenue Journal", + check_company=True, + ) + expense_cutoff_journal_id = fields.Many2one( + "account.journal", + string="Cut-off Expense Journal", + check_company=True, + ) diff --git a/account_move_cutoff/models/res_config_settings.py b/account_move_cutoff/models/res_config_settings.py new file mode 100644 index 00000000000..4f574473dc2 --- /dev/null +++ b/account_move_cutoff/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2023 Foodles (https://www.foodles.com/) +# @author Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + revenue_cutoff_journal_id = fields.Many2one( + related="company_id.revenue_cutoff_journal_id", + readonly=False, + string="Revenue cut-off journal", + ) + expense_cutoff_journal_id = fields.Many2one( + related="company_id.expense_cutoff_journal_id", + readonly=False, + string="Expense cut-off journal", + ) diff --git a/account_move_cutoff/readme/CONFIGURE.rst b/account_move_cutoff/readme/CONFIGURE.rst new file mode 100644 index 00000000000..16793abece6 --- /dev/null +++ b/account_move_cutoff/readme/CONFIGURE.rst @@ -0,0 +1,56 @@ +Deferred journal(s) +~~~~~~~~~~~~~~~~~~~ + +In accounting configuration you should set +Deferred Revenue and Expense journal to be used +on generated entries. + +.. note:: + + Journal will be used according the kind of + journal used by the former entry: `sale` or `purchase` + + +Deferred account(s) +~~~~~~~~~~~~~~~~~~~ + +On each Revenue/Expense account you can set the deferred +Revenue/Expense account. + +Only invoice lines linked to account with a deferred account set +will generate deferred Revenue/Expense. + + +Cut-off Method +~~~~~~~~~~~~~~ + +In the first version of this module, two cut-off computation methods are +supported and can be configured using the ``account_move_cutoff.default_cutoff_method`` +key. The currently possible values are ``monthly_prorata_temporis`` or ``equal``. + +Before defining these values, let's provide some context by using an example to +illustrate the definitions. Consider a sales invoice that is posted on January +16th for a service that spans from the 8th to the 15th of March. So, there are +24 days in January, 1 full month in February, and 15 days in March. The product +is sold for 1000 for a month, so the invoice line amount (excluding VAT) is +calculated as follows:: + + 24/31 * 1000 + 1000 + 15/31 * 1000 = 2258.06 + +* **monthly_prorata_temporis** (the default if not set): This method splits amounts + over the rate of the month the product has been used. The results would be as + follows: + + - January: **774.19** (`2258.06 - 1000 - 483.87`) (Subtraction is used here to avoid + rounding discrepancies.) + - February: **1000.00** (`1 * 2258.06 / (24/31 + 1 + 15/31)`) + - March: **483.87** (`15/31 * 2258.06 / (24/31 + 1 + 15/31)`) + +* **equal**: With this method, the same amount is split over the months of service. + + - January: **752.68** (`2258.06 - 752.69 - 752.69`) + - February: **752.69** (2258.06 / 3) + - March: **752.69** (2258.06 / 3) + +Please note that this information is subject to change based on updates to the +module. Always refer to the latest documentation for accurate details. diff --git a/account_move_cutoff/readme/DESCRIPTION.rst b/account_move_cutoff/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..22035665e31 --- /dev/null +++ b/account_move_cutoff/readme/DESCRIPTION.rst @@ -0,0 +1,22 @@ +This module allows to generate cu-toff entries automatically when posting former +entries. + +This module is based on `account_invoice_start_end_dates` +which allows to define start end end dates on invoice line (`account.move.line`). + + +Following assumption have been made before developing this module:: + + - New method to compute cutoff amounts can be add by business modules + + +.. note:: + + This module as been developed with some opinionated design + do not depends on `account_cutoff_base`. Because:: + + - we don't want rely on user nor async task at the end of + each month (period) + - link entries to understand the history without merging amounts + in order to be able to keep details on deferred account + move line (analytics, partners, accounts and so on) diff --git a/account_move_cutoff/readme/ROADMAP.rst b/account_move_cutoff/readme/ROADMAP.rst new file mode 100644 index 00000000000..efe72dfb540 --- /dev/null +++ b/account_move_cutoff/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +- Make the is_deferrable_line a storable field to allow end user to not + deferred a given line while posting entry. (but should raise if + it's not possible to force the value to true) +- allow to change/configure cutoff frequency (weekly/monthly/...) + today only monthly is implemented +- allow to configure cutoff computation method in different + place (product / invoice lines /...) diff --git a/account_move_cutoff/readme/USAGE.rst b/account_move_cutoff/readme/USAGE.rst new file mode 100644 index 00000000000..d77e17856b0 --- /dev/null +++ b/account_move_cutoff/readme/USAGE.rst @@ -0,0 +1,21 @@ + +To handle deferred accounting, follow these steps: + +1. Set the start and end date, where the end date is at least set to + the month after the current entry posted date. + +2. Ensure that the account (the `account.account` configuration) + in use is linked to a deferred account. + +3. Post the entry. + +4. After posting, check deferred entries have been generated, posted, and + reconciled if needed. + +.. note:: + + This module only defers amounts in periods subsequent to the accounting period + date. For example, if an invoice is posted on March 2nd for a service from + January 1st to March 31st at 1000€ per month, the deferred amount will be + only 1000€ for March, leaving 2000€ in February. We never add accounting items + in periods previous to the current entry date. diff --git a/account_move_cutoff/static/description/index.html b/account_move_cutoff/static/description/index.html new file mode 100644 index 00000000000..850f9e05a79 --- /dev/null +++ b/account_move_cutoff/static/description/index.html @@ -0,0 +1,529 @@ + + + + + + +Account Move Cut-off + + + +
+

Account Move Cut-off

+ + +

Beta License: AGPL-3 OCA/account-closing Translate me on Weblate Try me on Runbot

+

This module allows to generate cu-toff entries automatically when posting former +entries.

+

This module is based on account_invoice_start_end_dates +which allows to define start end end dates on invoice line (account.move.line).

+

Following assumption have been made before developing this module:

+
+- New method to compute cutoff amounts can be add by business modules
+
+
+

Note

+

This module as been developed with some opinionated design +do not depends on account_cutoff_base. Because:

+
+- we don't want rely on user nor async task at the end of
+  each month (period)
+- link entries to understand the history without merging amounts
+  in order to be able to keep details on deferred account
+  move line (analytics, partners, accounts and so on)
+
+
+

Table of contents

+ +
+

Configuration

+
+

Deferred journal(s)

+

In accounting configuration you should set +Deferred Revenue and Expense journal to be used +on generated entries.

+
+

Note

+

Journal will be used according the kind of +journal used by the former entry: sale or purchase

+
+
+
+

Deferred account(s)

+

On each Revenue/Expense account you can set the deferred +Revenue/Expense account.

+

Only invoice lines linked to account with a deferred account set +will generate deferred Revenue/Expense.

+
+
+

Cut-off Method

+

In the first version of this module, two cut-off computation methods are +supported and can be configured using the account_move_cutoff.default_cutoff_method +key. The currently possible values are monthly_prorata_temporis or equal.

+

Before defining these values, let’s provide some context by using an example to +illustrate the definitions. Consider a sales invoice that is posted on January +16th for a service that spans from the 8th to the 15th of March. So, there are +24 days in January, 1 full month in February, and 15 days in March. The product +is sold for 1000 for a month, so the invoice line amount (excluding VAT) is +calculated as follows:

+
+24/31 * 1000 + 1000 + 15/31 * 1000 = 2258.06
+
+
    +
  • monthly_prorata_temporis (the default if not set): This method splits amounts +over the rate of the month the product has been used. The results would be as +follows:
      +
    • January: 774.19 (2258.06 - 1000 - 483.87) (Subtraction is used here to avoid +rounding discrepancies.)
    • +
    • February: 1000.00 (1 * 2258.06 / (24/31 + 1 + 15/31))
    • +
    • March: 483.87 (15/31 * 2258.06 / (24/31 + 1 + 15/31))
    • +
    +
  • +
  • equal: With this method, the same amount is split over the months of service.
      +
    • January: 752.68 (2258.06 - 752.69 - 752.69)
    • +
    • February: 752.69 (2258.06 / 3)
    • +
    • March: 752.69 (2258.06 / 3)
    • +
    +
  • +
+

Please note that this information is subject to change based on updates to the +module. Always refer to the latest documentation for accurate details.

+
+
+
+

Usage

+

To handle deferred accounting, follow these steps:

+
    +
  1. Set the start and end date, where the end date is at least set to +the month after the current entry posted date.
  2. +
  3. Ensure that the account (the account.account configuration) +in use is linked to a deferred account.
  4. +
  5. Post the entry.
  6. +
  7. After posting, check deferred entries have been generated, posted, and +reconciled if needed.
  8. +
+
+

Note

+

This module only defers amounts in periods subsequent to the accounting period +date. For example, if an invoice is posted on March 2nd for a service from +January 1st to March 31st at 1000€ per month, the deferred amount will be +only 1000€ for March, leaving 2000€ in February. We never add accounting items +in periods previous to the current entry date.

+
+
+
+

Known issues / Roadmap

+
    +
  • Make the is_deferrable_line a storable field to allow end user to not +deferred a given line while posting entry. (but should raise if +it’s not possible to force the value to true)
  • +
  • allow to change/configure cutoff frequency (weekly/monthly/…) +today only monthly is implemented
  • +
  • allow to configure cutoff computation method in different +place (product / invoice lines /…)
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

petrus-v

+

This module is part of the OCA/account-closing project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/account_move_cutoff/tests/__init__.py b/account_move_cutoff/tests/__init__.py new file mode 100644 index 00000000000..0bd6524aeed --- /dev/null +++ b/account_move_cutoff/tests/__init__.py @@ -0,0 +1,7 @@ +from . import test_account_invoice_cutoff +from . import test_account_move_line +from . import test_account_move_bank_journal +from . import test_account_purchase_invoice_cutoff +from . import test_account_refund_cutoff +from . import test_cutoff_period_mixin +from . import test_account_supplier_refund_cutoff diff --git a/account_move_cutoff/tests/common.py b/account_move_cutoff/tests/common.py new file mode 100644 index 00000000000..e359600ceec --- /dev/null +++ b/account_move_cutoff/tests/common.py @@ -0,0 +1,246 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged +from odoo.tests.common import SavepointCase + + +@tagged("-at_install", "post_install") +class CommonAccountCutoffBaseCAse(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.maxDiff = None + cls.account_cutoff = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_current_liabilities").id, + ), + ("company_id", "=", cls.env.ref("base.main_company").id), + ], + limit=1, + ) + cls.account_cutoff.reconcile = True + cls.maint_product = cls.env.ref( + "account_invoice_start_end_dates.product_maintenance_contract_demo" + ) + cls.miscellaneous_journal = cls.env["account.journal"].search( + [("type", "=", "general"), ("code", "=", "MISC")], limit=1 + ) + cls.env.company.revenue_cutoff_journal_id = cls.miscellaneous_journal.id + cls.env.company.expense_cutoff_journal_id = cls.miscellaneous_journal.id + cls.analytic = cls.env["account.analytic.account"].create({"name": "test"}) + + @classmethod + def _create_invoice(cls, journal=None, move_type=None, account=None): + + return cls.env["account.move"].create( + { + "date": "2023-01-15", + "invoice_date": "2023-01-15", + "partner_id": cls.env.ref("base.res_partner_2").id, + "journal_id": journal.id, + "move_type": move_type, + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case A: 3 months starting the 7th", + "price_unit": 2400, + "quantity": 2, + "account_id": account.id, + "analytic_account_id": cls.analytic.id, + "start_date": "2023-01-07", + "end_date": "2023-03-31", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case B: 3 full months", + "price_unit": 12, + "quantity": 10, + "account_id": account.id, + "start_date": "2023-01-01", + "end_date": "2023-03-31", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case C: 2 month starting next month", + "price_unit": 12, + "quantity": 5, + "account_id": account.id, + "start_date": "2023-02-01", + "end_date": "2023-03-31", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case D: 2 month stopping the month before", + "price_unit": 130, + "quantity": 2, + "account_id": account.id, + "start_date": "2023-01-01", + "end_date": "2023-02-28", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case E: 1 month (22 october) leaving a blank months", + "price_unit": 113.5, + "quantity": 1, + "account_id": account.id, + "start_date": "2022-10-01", + "end_date": "2022-10-15", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case F: 3 months stating before invoice date (december)", + "price_unit": 777, + "quantity": 1, + "account_id": account.id, + "start_date": "2022-12-01", + "end_date": "2023-02-28", + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case G: 1 month (may) leaving a blank month (april)", + "price_unit": 255, + "quantity": 1, + "account_id": account.id, + "start_date": "2023-05-01", + "end_date": "2023-05-31", + }, + ), + ( + 0, + 0, + { + "product_id": cls.env.ref("product.product_product_5").id, + "name": "Case H: product without date", + "price_unit": 215, + "quantity": 1, + "account_id": account.id, + }, + ), + ( + 0, + 0, + { + "product_id": cls.maint_product.id, + "name": "Case I: 3 months starting the 17th stopping the 15th", + "price_unit": 2000, + "quantity": 1, + "account_id": account.id, + "start_date": "2023-01-17", + "end_date": "2023-03-15", + }, + ), + ], + } + ) + + def assertAccountMoveLines(self, account_move, expected_lines): + """Assert account move line values in an account move record + + :param expected_lines: list of tuple (filter_handler, dict(expected=value)) + :param filter_handler: is a filtered method that get an account move + line as parameter + :param dict(expected=value): a dict with values to test to be equals + + Usage:: + + self.assertExpectedAccountMoveLines( + invoice, [ + (lambda line: line.name == 'Expected string', {"name": "Expected string"}) + ] + ) + """ + for filter_function, expected_values in expected_lines: + lines = account_move.line_ids.filtered(filter_function) + if not lines: + self.assertTrue(False, "No lines found matching filter_handler method") + for line in lines: + for key, expected_value in expected_values.items(): + self.assertEqual( + getattr(line, key), + expected_value, + f"Testing {key} field on {line.name} ({len(lines)} " + "lines matched current filter)", + ) + + +class CommonAccountInvoiceCutoffCase(CommonAccountCutoffBaseCAse): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_revenue = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_revenue").id, + ) + ], + limit=1, + ) + cls.account_revenue.deferred_accrual_account_id = cls.account_cutoff + cls.sale_journal = cls.env["account.journal"].search( + [("type", "=", "sale")], limit=1 + ) + cls.invoice = cls._create_invoice( + journal=cls.sale_journal, + move_type="out_invoice", + account=cls.account_revenue, + ) + + +class CommonAccountPurchaseInvoiceCutoffCase(CommonAccountCutoffBaseCAse): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_expense = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_expenses").id, + ) + ], + limit=1, + ) + cls.account_expense.deferred_accrual_account_id = cls.account_cutoff + cls.purchase_journal = cls.env["account.journal"].search( + [("type", "=", "purchase")], limit=1 + ) + + cls.invoice = cls._create_invoice( + journal=cls.purchase_journal, + move_type="in_invoice", + account=cls.account_expense, + ) diff --git a/account_move_cutoff/tests/test_account_invoice_cutoff.py b/account_move_cutoff/tests/test_account_invoice_cutoff.py new file mode 100644 index 00000000000..8440acfcbd8 --- /dev/null +++ b/account_move_cutoff/tests/test_account_invoice_cutoff.py @@ -0,0 +1,540 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date + +from freezegun import freeze_time + +from odoo.tests import tagged + +from .common import CommonAccountInvoiceCutoffCase + + +@tagged("-at_install", "post_install") +class TestInvoiceCutoff(CommonAccountInvoiceCutoffCase): + def test_ensure_invoice_without_start_end_date_are_postable(self): + self.invoice.line_ids.product_id.must_have_dates = False + self.invoice.line_ids.write({"start_date": False, "end_date": False}) + self.invoice.action_post() + self.assertEqual(self.invoice.state, "posted") + + def test_get_deferred_periods_only_past_services(self): + self.invoice.date = date(2024, 1, 1) + self.assertEqual( + self.invoice._get_deferred_periods( + self.invoice.line_ids.filtered(lambda line: line.end_date) + ), + [date(2024, 1, 1)], + ) + + def test_account_invoice_cutoff_all_pasted_periods(self): + self.invoice.date = self.invoice.invoice_date = date(2023, 12, 1) + with freeze_time("2023-12-01"): + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 0) + + def test_account_invoice_cutoff_equals(self): + self.invoice.line_ids.cutoff_method = "equal" + with freeze_time("2023-01-15"): + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 4) + + def test_avoid_duplicated_entries(self): + with freeze_time("2023-01-15"): + self.invoice.action_post() + self.invoice.button_draft() + self.assertEqual(self.invoice.cutoff_move_count, 0) + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 4) + + def test_action_view_deferred_entries(self): + with freeze_time("2023-01-15"): + self.invoice.action_post() + action = self.invoice.action_view_deferred_entries() + self.assertEqual(action["domain"][0][2], self.invoice.cutoff_entry_ids.ids) + + def test_account_invoice_cutoff_monthly_factor_prorata(self): + self.invoice.line_ids.cutoff_method = "monthly_prorata_temporis" + + with freeze_time("2023-01-15"): + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 4) + + cutoff_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 1, 15): move.date == move_date + ) + + self.assertEqual(cutoff_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + cutoff_move.ref, + f"Advance revenue recognition of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + cutoff_move, + [ + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_revenue, + "credit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_cutoff, + "reconciled": True, + "debit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + { + "debit": 3420.68, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case A" in ml.name + ), + "analytic_account_id": self.analytic, + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + { + "debit": 80.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case B" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + { + "debit": 60.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case C" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case D" in ml.name, + { + "debit": 130.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case D" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case F" in ml.name, + { + "debit": 259.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case F" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case G" in ml.name, + { + "debit": 255.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case G" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 5, 1), + "end_date": date(2023, 5, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + { + "debit": 1508.19, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case I" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 15), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + cutoff_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped("credit") + ), + 5712.87, + 2, + ) + + deferred_feb_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 2, 1): move.date == move_date + ) + self.assertEqual(deferred_feb_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_feb_move.ref, + f"Advance revenue adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_feb_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_revenue, + "debit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "reconciled": True, + "credit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + { + "credit": 1710.34, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case A" in ml.name + ), + "analytic_account_id": self.analytic, + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + { + "credit": 40.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case B" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + { + "credit": 30.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case C" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case D" in ml.name, + { + "credit": 130.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case D" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case F" in ml.name, + { + "credit": 259.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case F" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + { + "credit": 1016.39, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case I" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_feb_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 3185.73, + 2, + ) + + deferred_mar_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 3, 1): move.date == move_date + ) + self.assertEqual(deferred_mar_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_mar_move.ref, + f"Advance revenue adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_mar_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_revenue, + "debit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "reconciled": True, + "credit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + { + "credit": 1710.34, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case A" in ml.name + ), + "analytic_account_id": self.analytic, + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + { + "credit": 40.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case B" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + { + "credit": 30.0, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case C" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + { + "credit": 491.80, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case I" in ml.name + ), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 15), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_mar_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 2272.14, + 2, + ) + + deferred_may_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 5, 1): move.date == move_date + ) + self.assertEqual(deferred_may_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_may_move.ref, + f"Advance revenue adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_may_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_revenue, + "debit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "reconciled": True, + "credit": 0.0, + "cutoff_source_move_id": self.invoice, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case G" in ml.name, + { + "credit": 255.00, + "cutoff_source_id": self.invoice.line_ids.filtered( + lambda ml, account=self.account_revenue: ml.account_id + == account + and "Case G" in ml.name + ), + "start_date": date(2023, 5, 1), + "end_date": date(2023, 5, 31), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_may_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 255.0, + 2, + ) diff --git a/account_move_cutoff/tests/test_account_move_bank_journal.py b/account_move_cutoff/tests/test_account_move_bank_journal.py new file mode 100644 index 00000000000..68a0cd0bce1 --- /dev/null +++ b/account_move_cutoff/tests/test_account_move_bank_journal.py @@ -0,0 +1,97 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged +from odoo.tests.common import SavepointCase + + +@tagged("-at_install", "post_install") +class NoCuttOfInBankJournal(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_cutoff = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_current_liabilities").id, + ), + ("company_id", "=", cls.env.ref("base.main_company").id), + ], + limit=1, + ) + cls.bank_journal = cls.env["account.journal"].search( + [("type", "=", "bank"), ("code", "=", "BNK1")], limit=1 + ) + cls.miscellaneous_journal = cls.env["account.journal"].search( + [("type", "=", "general"), ("code", "=", "MISC")], limit=1 + ) + cls.env.company.revenue_cutoff_journal_id = cls.miscellaneous_journal.id + cls.env.company.expense_cutoff_journal_id = cls.miscellaneous_journal.id + + def test_no_cutoff_in_bank_journal(self): + # here we wants to test the weird case where account deferred_accrual_account_id + # would be set but as bank journal we could not determin proper journal to use + self.bank_journal.default_account_id.deferred_accrual_account_id = ( + self.account_cutoff + ) + partner = self.env.ref("base.res_partner_2") + entry = self.env["account.move"].create( + { + "date": "2023-01-15", + "move_type": "entry", + "partner_id": partner.id, + "journal_id": self.bank_journal.id, + "line_ids": [ + ( + 0, + 0, + { + "name": "some amounts with date defined", + "debit": 2400, + "credit": 0, + "account_id": self.bank_journal.default_account_id.id, + "start_date": "2023-01-07", + "end_date": "2023-03-25", + }, + ), + ( + 0, + 0, + { + "name": "some amounts with date defined", + "debit": 0, + "credit": 2400, + "account_id": partner.property_account_receivable_id.id, + "start_date": "2023-01-07", + "end_date": "2023-03-25", + }, + ), + ], + } + ) + entry.action_post() + # call it directly as entry are currently not supported ! + entry._create_cutoff_entries( + entry.line_ids.filtered(lambda line: line.deferred_accrual_account_id) + ) + self.assertEqual(entry.cutoff_move_count, 0) + # in case other module let other journal to generate cut-off + # hard to say if it's revenue or expense test neutral references + self.assertEqual( + entry._get_deferred_titles(), + ( + "Advance recognition of %s (%s)" + % ( + entry.name, + entry.date.strftime("%m %Y"), + ), + "Advance adjustment of %s (%s)" + % ( + entry.name, + entry.date.strftime("%m %Y"), + ), + ), + ) diff --git a/account_move_cutoff/tests/test_account_move_line.py b/account_move_cutoff/tests/test_account_move_line.py new file mode 100644 index 00000000000..a24d04914ad --- /dev/null +++ b/account_move_cutoff/tests/test_account_move_line.py @@ -0,0 +1,642 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from freezegun import freeze_time +from parameterized import parameterized + +from odoo.tests import tagged + +from .common import CommonAccountInvoiceCutoffCase + + +@tagged("-at_install", "post_install") +class TestAccountMoveLine(CommonAccountInvoiceCutoffCase): + @parameterized.expand( + [ + ("Two months", "2023-01-08", "2023-01-01", "2023-02-28", True), + ("Next month", "2023-01-08", "2023-02-01", "2023-02-28", True), + ("Same month", "2023-02-08", "2023-02-01", "2023-02-28", False), + ] + ) + def test_account_move_line_has_deferred_dates( + self, + _test_name, + invoice_date, + start_date, + end_date, + expected_has_deferred_dates, + ): + self.invoice.date = invoice_date + move_line = self.invoice.line_ids[0] + move_line.start_date = start_date + move_line.end_date = end_date + self.assertEqual(move_line.has_deferred_dates(), expected_has_deferred_dates) + + @parameterized.expand( + [ + ( + "Two months", + "2023-01-08", + "2023-01-01", + "2023-02-28", + False, + False, + True, + ), + ( + "Next month", + "2023-01-08", + "2023-02-01", + "2023-02-28", + False, + False, + True, + ), + ( + "month before", + "2023-02-01", + "2023-01-01", + "2023-01-31", + False, + False, + False, + ), + ( + "Next month already generated", + "2023-01-08", + "2023-02-01", + "2023-02-28", + False, + True, + False, + ), + ( + "Same month", + "2023-02-08", + "2023-02-01", + "2023-02-28", + False, + False, + False, + ), + ( + "No cut-off account", + "2023-01-08", + "2023-01-01", + "2023-02-28", + True, + False, + False, + ), + # Testing end date or start date false alone is not a possible case + # because existing constraints + ("No start/end date", "2023-01-08", False, False, False, False, False), + ] + ) + def test_account_move_line_is_deferrable_line( + self, + _test_name, + invoice_date, + start_date, + end_date, + without_cutoff_account, + already_generated, + expected_is_deferrable_line, + ): + if without_cutoff_account: + self.account_revenue.deferred_accrual_account_id = False + self.invoice.date = invoice_date + move_line = self.invoice.line_ids[0] + move_line.write( + { + "start_date": start_date, + "end_date": end_date, + } + ) + if already_generated: + with freeze_time(invoice_date): + self.invoice.action_post() + self.assertEqual(move_line.is_deferrable_line, expected_is_deferrable_line) + + @parameterized.expand( + [ + ( + "1 to 15 jan", + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 15), + ( + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 15), + ), + ), + ( + "15 to 15 jan", + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 1, 15), + ( + datetime.date(2023, 1, 15), + datetime.date(2023, 1, 15), + ), + ), + ( + "15 to 31 jan", + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 2, 15), + ( + datetime.date(2023, 1, 15), + datetime.date(2023, 1, 31), + ), + ), + ( + "1 to 15 feb", + datetime.date(2023, 2, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 2, 15), + ( + datetime.date(2023, 2, 1), + datetime.date(2023, 2, 15), + ), + ), + ( + "month in the middle: 1 to 28 feb", + datetime.date(2023, 2, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 3, 15), + ( + datetime.date(2023, 2, 1), + datetime.date(2023, 2, 28), + ), + ), + ( + "No matched on this next period", + datetime.date(2023, 2, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 1, 15), + ( + None, + None, + ), + ), + ( + "No matched on the previous period", + datetime.date(2022, 12, 1), + datetime.date(2023, 1, 15), + datetime.date(2023, 1, 15), + ( + None, + None, + ), + ), + ] + ) + def test_get_period_start_end_dates( + self, _test_name, period, line_start, line_end, expected_start_end + ): + + move_line = self.invoice.line_ids[0] + move_line.write({"start_date": line_start, "end_date": line_end}) + self.assertEqual( + move_line._get_period_start_end_dates(period), expected_start_end + ) + + @parameterized.expand( + [ + ( + "no change", + { + datetime.date(2022, 12, 1): 2400, + datetime.date(2023, 1, 1): 2400, + }, + { + datetime.date(2022, 12, 1): 2400.0, + datetime.date(2023, 1, 1): 2400.0, + }, + ), + ( + "fix rounding", + { + datetime.date(2022, 12, 1): 2400.01, + datetime.date(2023, 1, 1): 2400.01, + }, + { + datetime.date(2022, 12, 1): 2399.99, + datetime.date(2023, 1, 1): 2400.01, + }, + ), + ( + "rounds 2 periods", + { + datetime.date(2022, 12, 1): 2400.00666, + datetime.date(2023, 1, 1): 2400.00666, + }, + { + datetime.date(2022, 12, 1): 2399.99, + datetime.date(2023, 1, 1): 2400.01, + }, + ), + ( + "rounds 3 periods", + { + datetime.date(2022, 12, 1): 1600.00666, + datetime.date(2023, 1, 1): 1600.00666, + datetime.date(2023, 2, 1): 1600.00666, + }, + { + datetime.date(2022, 12, 1): 1599.98, + datetime.date(2023, 1, 1): 1600.01, + datetime.date(2023, 2, 1): 1600.01, + }, + ), + ( + "rounds 1 period", + { + datetime.date(2022, 12, 1): 4800.0003, + }, + { + datetime.date(2022, 12, 1): 4800.00, + }, + ), + ( + "rounds nothing", + {}, + {}, + ), + ] + ) + def test_round_amounts(self, _test_name, periods_amounts, expected_periods_amounts): + move_line = self.invoice.line_ids[0] + results = move_line._round_amounts(periods_amounts) + for expected_key, expected_value in expected_periods_amounts.items(): + self.assertAlmostEqual( + results[expected_key], + expected_value, + 2, + ) + + @parameterized.expand( + [ + ( + "No changes", + { + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ], + { + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + }, + ), + ( + "One previous", + { + datetime.date(2022, 12, 1): 1600.0, + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ], + { + datetime.date(2023, 1, 1): 3200.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + }, + ), + ( + "One after", + { + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + datetime.date(2023, 4, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ], + { + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 3200.0, + }, + ), + ( + "one ok", + { + datetime.date(2023, 1, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + ], + { + datetime.date(2023, 1, 1): 1600.0, + }, + ), + ( + "one period before", + { + datetime.date(2022, 1, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ], + { + datetime.date(2023, 1, 1): 1600.0, + # as long we are using defaultdict + # this is ok + # datetime.date(2023, 2, 1): 0.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "one period after", + { + datetime.date(2024, 1, 1): 1600.0, + }, + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + ], + { + datetime.date(2023, 1, 1): 0.0, + datetime.date(2023, 2, 1): 1600.0, + }, + ), + ( + "multiple before-after", + { + datetime.date(2022, 1, 1): 1600.0, + datetime.date(2022, 2, 1): 1600.0, + datetime.date(2022, 6, 1): 1600.0, + datetime.date(2022, 7, 1): 1600.0, + }, + [ + datetime.date(2023, 4, 1), + ], + { + datetime.date(2023, 4, 1): 6400.0, + }, + ), + ( + "no amounts", + {}, + [ + datetime.date(2023, 3, 1), + datetime.date(2023, 4, 1), + ], + { + datetime.date(2023, 3, 1): 0.0, + datetime.date(2023, 4, 1): 0.0, + }, + ), + ] + ) + def test_line_amounts_on_proper_periods( + self, _name, line_amounts, periods, expected + ): + self.assertEqual( + dict( + self.env["account.move.line"]._line_amounts_on_proper_periods( + line_amounts, periods + ) + ), + expected, + ) + + @parameterized.expand( + [ + ( + "Case A", + None, + { + datetime.date(2023, 1, 1): 1600.0, + datetime.date(2023, 2, 1): 1600.0, + datetime.date(2023, 3, 1): 1600.0, + }, + ), + ( + "Case B", + None, + { + datetime.date(2023, 1, 1): 40.0, + datetime.date(2023, 2, 1): 40.0, + datetime.date(2023, 3, 1): 40.0, + }, + ), + ( + "Case C", + None, + { + datetime.date(2023, 1, 1): 0.0, + datetime.date(2023, 2, 1): 30.0, + datetime.date(2023, 3, 1): 30.0, + }, + ), + ( + "Case D", + None, + { + datetime.date(2023, 1, 1): 130.0, + datetime.date(2023, 2, 1): 130.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case E", + None, + { + datetime.date(2023, 1, 1): 113.5, + # because defaultdict + # datetime.date(2023, 2, 1): 0.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case F", + None, + { + datetime.date(2023, 1, 1): 518.0, + # because defaultdict + datetime.date(2023, 2, 1): 259.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case G", + None, + { + datetime.date(2023, 1, 1): 0, + # because defaultdict + # datetime.date(2023, 2, 1): 0.0, + datetime.date(2023, 3, 1): 255, + }, + ), + ( + "Case G", + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + datetime.date(2023, 4, 1), + datetime.date(2023, 5, 1), + ], + { + datetime.date(2023, 1, 1): 0.0, + datetime.date(2023, 5, 1): 255.0, + }, + ), + ( + "Case I", + None, + { + datetime.date(2023, 1, 1): 666.66, + datetime.date(2023, 2, 1): 666.67, + datetime.date(2023, 3, 1): 666.67, + }, + ), + ] + ) + def test_get_amounts_per_periods_equals(self, line_case, periods, expected): + + if not periods: + periods = [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ] + move_line = self.invoice.line_ids.filtered( + lambda line: line.name.startswith(line_case) + ) + move_line.cutoff_method = "equal" + self.assertEqual(move_line._get_amounts_per_periods(periods), expected) + + @parameterized.expand( + [ + ( + "Case A", + None, + { + datetime.date(2023, 1, 1): 1379.32, + datetime.date(2023, 2, 1): 1710.34, + datetime.date(2023, 3, 1): 1710.34, + }, + ), + ( + "Case B", + None, + { + datetime.date(2023, 1, 1): 40.0, + datetime.date(2023, 2, 1): 40.0, + datetime.date(2023, 3, 1): 40.0, + }, + ), + ( + "Case C", + None, + { + datetime.date(2023, 1, 1): 0.0, + datetime.date(2023, 2, 1): 30.0, + datetime.date(2023, 3, 1): 30.0, + }, + ), + ( + "Case D", + None, + { + datetime.date(2023, 1, 1): 130.0, + datetime.date(2023, 2, 1): 130.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case E", + None, + { + datetime.date(2023, 1, 1): 113.5, + # datetime.date(2023, 2, 1): 0.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case F", + None, + { + datetime.date(2023, 1, 1): 518.0, + datetime.date(2023, 2, 1): 259.0, + datetime.date(2023, 3, 1): 0.0, + }, + ), + ( + "Case G", + None, + { + datetime.date(2023, 1, 1): 0, + # datetime.date(2023, 2, 1): 0.0, + datetime.date(2023, 3, 1): 255, + }, + ), + ( + "Case G", + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + datetime.date(2023, 4, 1), + datetime.date(2023, 5, 1), + ], + { + datetime.date(2023, 1, 1): 0.0, + datetime.date(2023, 5, 1): 255.0, + }, + ), + ( + "Case I", + None, + { + datetime.date(2023, 1, 1): 491.81, + datetime.date(2023, 2, 1): 1016.39, + datetime.date(2023, 3, 1): 491.80, + }, + ), + ] + ) + def test_get_amounts_per_periods_monthly_prorata( + self, line_case, periods, expected + ): + + if not periods: + periods = [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ] + move_line = self.invoice.line_ids.filtered( + lambda line: line.name.startswith(line_case) + ) + move_line.cutoff_method = "monthly_prorata_temporis" + result = move_line._get_amounts_per_periods(periods) + for period, expected_amount in expected.items(): + self.assertAlmostEqual(result[period], expected_amount, 2) + + def test_prepare_entry_lines_with_null_amount(self): + self.assertFalse( + self.invoice.line_ids[0]._prepare_entry_lines(self.invoice, None, 0) + ) diff --git a/account_move_cutoff/tests/test_account_purchase_invoice_cutoff.py b/account_move_cutoff/tests/test_account_purchase_invoice_cutoff.py new file mode 100644 index 00000000000..bfa46d28234 --- /dev/null +++ b/account_move_cutoff/tests/test_account_purchase_invoice_cutoff.py @@ -0,0 +1,247 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date + +from freezegun import freeze_time + +from odoo.tests import tagged + +from .common import CommonAccountPurchaseInvoiceCutoffCase + + +@tagged("-at_install", "post_install") +class TestPurchaseInvoiceCutoff(CommonAccountPurchaseInvoiceCutoffCase): + def test_ensure_invoice_without_start_end_date_are_postable(self): + self.invoice.line_ids.product_id.must_have_dates = False + self.invoice.line_ids.write({"start_date": False, "end_date": False}) + self.invoice.action_post() + self.assertEqual(self.invoice.state, "posted") + + def test_account_purchase_invoice_cutoff_equals(self): + # self.env["ir.config_parameter"].set_param( + # "account_move_cutoff.default_cutoff_method", + # "equal" + # ) + self.invoice.line_ids.cutoff_method = "equal" + with freeze_time("2023-01-01"): + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 4) + + def test_account_purchase_invoice_cutoff_monthly_factor_prorata(self): + self.invoice.line_ids.cutoff_method = "monthly_prorata_temporis" + + with freeze_time("2023-01-01"): + self.invoice.action_post() + self.assertEqual(self.invoice.cutoff_move_count, 4) + + cutoff_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 1, 15): move.date == move_date + ) + + self.assertEqual(cutoff_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + cutoff_move.ref, + f"Advance expense recognition of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + cutoff_move, + [ + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_expense, "debit": 0.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + {"credit": 3420.68}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + {"credit": 80.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + {"credit": 60.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case D" in ml.name, + {"credit": 130.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case F" in ml.name, + {"credit": 259.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case G" in ml.name, + {"credit": 255.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + {"credit": 1508.19}, + ), + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_cutoff, "credit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum(cutoff_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped("debit")), + 5712.87, + 2, + ) + + deferred_feb_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 2, 1): move.date == move_date + ) + self.assertEqual(deferred_feb_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_feb_move.ref, + f"Advance expense adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_feb_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_expense, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + {"debit": 1710.34}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + {"debit": 40.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + {"debit": 30.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case D" in ml.name, + {"debit": 130.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case F" in ml.name, + {"debit": 259.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + {"debit": 1016.39}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_feb_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped( + "credit" + ) + ), + 3185.73, + 2, + ) + + deferred_mar_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 3, 1): move.date == move_date + ) + self.assertEqual(deferred_mar_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_mar_move.ref, + f"Advance expense adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_mar_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_expense, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + {"debit": 1710.34}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + {"debit": 40.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + {"debit": 30.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + {"debit": 491.80}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_mar_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 2272.14, + 2, + ) + + deferred_may_move = self.invoice.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 5, 1): move.date == move_date + ) + self.assertEqual(deferred_may_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_may_move.ref, + f"Advance expense adjustment of {self.invoice.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_may_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_expense, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case G" in ml.name, + {"debit": 255.00}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_may_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped( + "credit" + ) + ), + 255.0, + 2, + ) diff --git a/account_move_cutoff/tests/test_account_refund_cutoff.py b/account_move_cutoff/tests/test_account_refund_cutoff.py new file mode 100644 index 00000000000..9de513ca1f4 --- /dev/null +++ b/account_move_cutoff/tests/test_account_refund_cutoff.py @@ -0,0 +1,270 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date + +from freezegun import freeze_time + +from odoo.tests import tagged + +from .common import CommonAccountCutoffBaseCAse + + +@tagged("-at_install", "post_install") +class TestRefundCutoff(CommonAccountCutoffBaseCAse): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_revenue = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_revenue").id, + ) + ], + limit=1, + ) + cls.account_revenue.deferred_accrual_account_id = cls.account_cutoff + cls.sale_journal = cls.env["account.journal"].search( + [("type", "=", "sale")], limit=1 + ) + cls.refund = cls._create_invoice( + journal=cls.sale_journal, + move_type="out_refund", + account=cls.account_revenue, + ) + + def test_ensure_refund_without_start_end_date_are_postable(self): + self.refund.line_ids.product_id.must_have_dates = False + self.refund.line_ids.write({"start_date": False, "end_date": False}) + self.refund.action_post() + self.assertEqual(self.refund.state, "posted") + + def test_account_refund_cutoff_equals(self): + # self.env["ir.config_parameter"].set_param( + # "account_move_cutoff.default_cutoff_method", + # "equal" + # ) + self.refund.line_ids.cutoff_method = "equal" + with freeze_time("2023-01-15"): + self.refund.action_post() + self.assertEqual(self.refund.cutoff_move_count, 4) + + def test_account_refund_cutoff_monthly_factor_prorata(self): + self.refund.line_ids.cutoff_method = "monthly_prorata_temporis" + + with freeze_time("2023-01-15"): + self.refund.action_post() + self.assertEqual(self.refund.cutoff_move_count, 4) + + cutoff_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 1, 15): move.date == move_date + ) + + self.assertEqual(cutoff_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + cutoff_move.ref, + f"Advance revenue recognition of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + cutoff_move, + [ + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_revenue, "debit": 0.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + {"credit": 3420.68}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + {"credit": 80.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + {"credit": 60.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case D" in ml.name, + {"credit": 130.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case F" in ml.name, + {"credit": 259.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case G" in ml.name, + {"credit": 255.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + {"credit": 1508.19}, + ), + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_cutoff, "credit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum(cutoff_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped("debit")), + 5712.87, + 2, + ) + + deferred_feb_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 2, 1): move.date == move_date + ) + self.assertEqual(deferred_feb_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_feb_move.ref, + f"Advance revenue adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_feb_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_revenue, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + {"debit": 1710.34}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + {"debit": 40.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + {"debit": 30.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case D" in ml.name, + {"debit": 130.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case F" in ml.name, + {"debit": 259.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + {"debit": 1016.39}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_feb_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped( + "credit" + ) + ), + 3185.73, + 2, + ) + + deferred_mar_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 3, 1): move.date == move_date + ) + self.assertEqual(deferred_mar_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_mar_move.ref, + f"Advance revenue adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_mar_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_revenue, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case A" in ml.name, + {"debit": 1710.34}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case B" in ml.name, + {"debit": 40.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case C" in ml.name, + {"debit": 30.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case I" in ml.name, + {"debit": 491.80}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_mar_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 2272.14, + 2, + ) + + deferred_may_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 5, 1): move.date == move_date + ) + self.assertEqual(deferred_may_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_may_move.ref, + f"Advance revenue adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_may_move, + [ + ( + lambda ml: ml.debit > 0, + {"account_id": self.account_revenue, "credit": 0.0}, + ), + ( + lambda ml, account=self.account_revenue: ml.account_id == account + and "Case G" in ml.name, + {"debit": 255.00}, + ), + ( + lambda ml: ml.credit > 0, + {"account_id": self.account_cutoff, "debit": 0.0}, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_may_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped( + "credit" + ) + ), + 255.0, + 2, + ) diff --git a/account_move_cutoff/tests/test_account_supplier_refund_cutoff.py b/account_move_cutoff/tests/test_account_supplier_refund_cutoff.py new file mode 100644 index 00000000000..1f48e3c81de --- /dev/null +++ b/account_move_cutoff/tests/test_account_supplier_refund_cutoff.py @@ -0,0 +1,438 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date + +from freezegun import freeze_time + +from odoo.tests import tagged + +from .common import CommonAccountCutoffBaseCAse + + +@tagged("-at_install", "post_install") +class TestSupplierRefundCutoff(CommonAccountCutoffBaseCAse): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_expense = cls.env["account.account"].search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_expenses").id, + ) + ], + limit=1, + ) + cls.account_expense.deferred_accrual_account_id = cls.account_cutoff + cls.purchase_journal = cls.env["account.journal"].search( + [("type", "=", "purchase")], limit=1 + ) + + cls.refund = cls._create_invoice( + journal=cls.purchase_journal, + move_type="in_refund", + account=cls.account_expense, + ) + + def test_ensure_refund_without_start_end_date_are_postable(self): + self.refund.line_ids.product_id.must_have_dates = False + self.refund.line_ids.write({"start_date": False, "end_date": False}) + self.refund.action_post() + self.assertEqual(self.refund.state, "posted") + + def test_account_refund_cutoff_equals(self): + # self.env["ir.config_parameter"].set_param( + # "account_move_cutoff.default_cutoff_method", + # "equal" + # ) + self.refund.line_ids.cutoff_method = "equal" + with freeze_time("2023-01-15"): + self.refund.action_post() + self.assertEqual(self.refund.cutoff_move_count, 4) + + def test_account_refund_cutoff_monthly_factor_prorata(self): + self.refund.line_ids.cutoff_method = "monthly_prorata_temporis" + + with freeze_time("2023-01-15"): + self.refund.action_post() + + self.assertEqual(self.refund.cutoff_move_count, 4) + + cutoff_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 1, 15): move.date == move_date + ) + + self.assertEqual(cutoff_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + cutoff_move.ref, + f"Advance expense recognition of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + cutoff_move, + [ + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_expense, + "credit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_cutoff, + "debit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + { + "debit": 3420.68, + "analytic_account_id": self.analytic, + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + { + "debit": 80.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + { + "debit": 60.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case D" in ml.name, + { + "debit": 130.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case F" in ml.name, + { + "debit": 259.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case G" in ml.name, + { + "debit": 255.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 5, 1), + "end_date": date(2023, 5, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + { + "debit": 1508.19, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 3, 15), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + cutoff_move.line_ids.filtered(lambda ml: ml.credit > 0).mapped("credit") + ), + 5712.87, + 2, + ) + + deferred_feb_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 2, 1): move.date == move_date + ) + self.assertEqual(deferred_feb_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_feb_move.ref, + f"Advance expense adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_feb_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_expense, + "debit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "credit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + { + "credit": 1710.34, + "analytic_account_id": self.analytic, + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + { + "credit": 40.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + { + "credit": 30.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case D" in ml.name, + { + "credit": 130.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case F" in ml.name, + { + "credit": 259.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + { + "credit": 1016.39, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 2, 1), + "end_date": date(2023, 2, 28), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_feb_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 3185.73, + 2, + ) + + deferred_mar_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 3, 1): move.date == move_date + ) + self.assertEqual(deferred_mar_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_mar_move.ref, + f"Advance expense adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_mar_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_expense, + "debit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "credit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case A" in ml.name, + { + "credit": 1710.34, + "analytic_account_id": self.analytic, + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case B" in ml.name, + { + "credit": 40.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case C" in ml.name, + { + "credit": 30.0, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 31), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case I" in ml.name, + { + "credit": 491.80, + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + "start_date": date(2023, 3, 1), + "end_date": date(2023, 3, 15), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_mar_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 2272.14, + 2, + ) + + deferred_may_move = self.refund.cutoff_entry_ids.filtered( + lambda move, move_date=date(2023, 5, 1): move.date == move_date + ) + self.assertEqual(deferred_may_move.journal_id, self.miscellaneous_journal) + self.assertEqual( + deferred_may_move.ref, + f"Advance expense adjustment of {self.refund.name} (01 2023)", + ) + self.assertAccountMoveLines( + deferred_may_move, + [ + ( + lambda ml: ml.credit > 0, + { + "account_id": self.account_expense, + "debit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml: ml.debit > 0, + { + "account_id": self.account_cutoff, + "credit": 0.0, + "partner_id": self.env.ref("base.res_partner_2"), + "analytic_account_id": self.env[ + "account.analytic.account" + ].browse(), + }, + ), + ( + lambda ml, account=self.account_expense: ml.account_id == account + and "Case G" in ml.name, + { + "credit": 255.00, + "start_date": date(2023, 5, 1), + "end_date": date(2023, 5, 31), + }, + ), + ], + ) + self.assertAlmostEqual( + sum( + deferred_may_move.line_ids.filtered(lambda ml: ml.debit > 0).mapped( + "debit" + ) + ), + 255.0, + 2, + ) diff --git a/account_move_cutoff/tests/test_cutoff_period_mixin.py b/account_move_cutoff/tests/test_cutoff_period_mixin.py new file mode 100644 index 00000000000..17bd1857483 --- /dev/null +++ b/account_move_cutoff/tests/test_cutoff_period_mixin.py @@ -0,0 +1,71 @@ +# Copyright 2023 Foodles (https://www.foodles.co/) +# @author: Pierre Verkest +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from parameterized import parameterized + +from odoo.tests import tagged + +from .common import CommonAccountInvoiceCutoffCase + + +@tagged("-at_install", "post_install") +class TestCutoffPeriodMixin(CommonAccountInvoiceCutoffCase): + @parameterized.expand( + [ + (datetime.date(2023, 1, 1), datetime.date(2023, 1, 1)), + (datetime.date(2023, 1, 8), datetime.date(2023, 1, 1)), + (datetime.datetime(2023, 1, 1, 2, 3), datetime.date(2023, 1, 1)), + (datetime.datetime(2023, 1, 8, 3, 1), datetime.date(2023, 1, 1)), + ] + ) + def test_period_from_date(self, test_date, expected_first_of_month): + self.assertEqual( + self.env["account.move"]._period_from_date(test_date), + expected_first_of_month, + ) + + @parameterized.expand( + [ + (datetime.date(2023, 1, 8), datetime.date(2023, 1, 31)), + (datetime.date(2023, 1, 31), datetime.date(2023, 1, 31)), + (datetime.date(2023, 12, 31), datetime.date(2023, 12, 31)), + (datetime.datetime(2023, 2, 1, 2, 3), datetime.date(2023, 2, 28)), + (datetime.datetime(2023, 2, 28, 3, 1), datetime.date(2023, 2, 28)), + ] + ) + def test_last_day_of_month(self, test_date, expected_last_of_month): + self.assertEqual( + self.env["account.move"]._last_day_of_month(test_date), + expected_last_of_month, + ) + + @parameterized.expand( + [ + ( + datetime.date(2023, 1, 1), + datetime.date(2023, 1, 31), + [datetime.date(2023, 1, 1)], + ), + ( + datetime.date(2023, 1, 8), + datetime.date(2023, 1, 28), + [datetime.date(2023, 1, 1)], + ), + ( + datetime.date(2023, 1, 17), + datetime.date(2023, 3, 18), + [ + datetime.date(2023, 1, 1), + datetime.date(2023, 2, 1), + datetime.date(2023, 3, 1), + ], + ), + ] + ) + def test_generate_monthly_periods(self, start_date, end_date, expected_periods): + self.assertEqual( + self.env["account.move"]._generate_monthly_periods(start_date, end_date), + expected_periods, + ) diff --git a/account_move_cutoff/views/account_account.xml b/account_move_cutoff/views/account_account.xml new file mode 100644 index 00000000000..ca55f1cdcbb --- /dev/null +++ b/account_move_cutoff/views/account_account.xml @@ -0,0 +1,29 @@ + + + + + + account.account.form + account.account + + + + + + + + + + account.account.list + account.account + + + + + + + + + diff --git a/account_move_cutoff/views/account_move.xml b/account_move_cutoff/views/account_move.xml new file mode 100644 index 00000000000..1b402ca08cf --- /dev/null +++ b/account_move_cutoff/views/account_move.xml @@ -0,0 +1,44 @@ + + + + + + + account.move.form + account.move + + +
+ +
+ + + + + + + + +
+
diff --git a/account_move_cutoff/views/account_move_line.xml b/account_move_cutoff/views/account_move_line.xml new file mode 100644 index 00000000000..6cface65cbe --- /dev/null +++ b/account_move_cutoff/views/account_move_line.xml @@ -0,0 +1,51 @@ + + + + + + account.move.line.form + account.move.line + + + + + + + + + + + + + + account.move.line.tree + account.move.line + + + + + + + + + + + + account.move.line.tree.grouped + account.move.line + + + + + + + + + + + diff --git a/account_move_cutoff/views/res_config_settings.xml b/account_move_cutoff/views/res_config_settings.xml new file mode 100644 index 00000000000..4300eaf2986 --- /dev/null +++ b/account_move_cutoff/views/res_config_settings.xml @@ -0,0 +1,38 @@ + + + + + cutoff.res.config.settings.form + res.config.settings + + + + +
+
+
+ Cutoff journal +
+ Revenues +
+
+ +
+
+ Expenses +
+
+ +
+
+
+ + + + + diff --git a/setup/account_move_cutoff/odoo/addons/account_move_cutoff b/setup/account_move_cutoff/odoo/addons/account_move_cutoff new file mode 120000 index 00000000000..c39b30d0f51 --- /dev/null +++ b/setup/account_move_cutoff/odoo/addons/account_move_cutoff @@ -0,0 +1 @@ +../../../../account_move_cutoff \ No newline at end of file diff --git a/setup/account_move_cutoff/setup.py b/setup/account_move_cutoff/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_move_cutoff/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000000..f13075aa364 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +freezegun +parameterized