diff --git a/sale_order_invoice_untaxed_amount/.gitignore b/sale_order_invoice_untaxed_amount/.gitignore new file mode 100644 index 0000000..6da5887 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/.gitignore @@ -0,0 +1,2 @@ +*.*~ +*pyc diff --git a/sale_order_invoice_untaxed_amount/README.md b/sale_order_invoice_untaxed_amount/README.md new file mode 100644 index 0000000..f8d9d69 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/README.md @@ -0,0 +1,65 @@ +# sale_order_invoice_untaxed_amount + +Display the invoiced and uninvoiced untaxed total in the sale order. + +This module adds two stored computed fields on the sale order: + +- **Invoiced Untaxed Amount**: the sum of the untaxed amounts of all + non-cancelled invoices linked to the sale order. +- **Uninvoiced Untaxed Amount**: the remaining untaxed amount still to be + invoiced (`amount_untaxed - invoiced_untaxed_amount`, never negative). + +Both fields are displayed: + +- In the **sale order form**, below the tax totals block. +- In the **sale order list view**, as optional columns (hidden by default) + with column totals. + +## Installation + +Use Odoo normal module installation procedure to install +`sale_order_invoice_untaxed_amount`. + +## Configuration + +This module does not require any configuration. + +## Usage + +1. Open or create a sale order. +2. Confirm the order and create one or more invoices from it. +3. The **Invoiced Untaxed Amount** and **Uninvoiced Untaxed Amount** are + displayed below the totals block of the sale order form. +4. In the sale orders list view, enable the optional columns **Invoiced + Untaxed Amount** and **Uninvoiced Untaxed Amount** to compare invoiced and + remaining untaxed amounts across orders. + +The amounts are recomputed automatically whenever the order state, its +invoices, or the invoices state change. + +## Known issues / Roadmap + +None yet. + +## Bug Tracker + +Bugs are tracked on [our issues website](https://github.com/elabore-coop/sale-tools/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. + +## Credits + +### Contributors + +- Stéphan Sainléger + +### Funders + +The development of this module has been financially supported by: + +- Elabore (https://elabore.coop) + +### Maintainer + +This module is maintained by Elabore. diff --git a/sale_order_invoice_untaxed_amount/__init__.py b/sale_order_invoice_untaxed_amount/__init__.py new file mode 100644 index 0000000..fd46080 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/__init__.py @@ -0,0 +1,3 @@ + +from . import models + diff --git a/sale_order_invoice_untaxed_amount/__manifest__.py b/sale_order_invoice_untaxed_amount/__manifest__.py new file mode 100644 index 0000000..e3956d8 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "sale_order_invoice_untaxed_amount", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://elabore.coop", + "maintainer": "Stéphan Sainléger", + "license": "AGPL-3", + "category": "Tools", + "summary": "Display the invoiced and uninvoiced untaxed total in the sale order", + # any module necessary for this one to work correctly + "depends": [ + "account", + "sale", + ], + "qweb": [ + # "static/src/xml/*.xml", + ], + "external_dependencies": { + "python": [], + }, + # always loaded + "data": [ + "views/sale_order_view.xml", + ], + # only loaded in demonstration mode + "demo": [], + "js": [], + "css": [], + "installable": True, + # Install this module automatically if all dependency have been previously + # and independently installed. Used for synergetic or glue modules. + "auto_install": False, + "application": False, +} \ No newline at end of file diff --git a/sale_order_invoice_untaxed_amount/i18n/README b/sale_order_invoice_untaxed_amount/i18n/README new file mode 100644 index 0000000..62197a1 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/i18n/README @@ -0,0 +1 @@ +This directory should contain the *.po for Odoo translation. diff --git a/sale_order_invoice_untaxed_amount/i18n/fr.po b/sale_order_invoice_untaxed_amount/i18n/fr.po new file mode 100644 index 0000000..707f5f7 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/i18n/fr.po @@ -0,0 +1,56 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_invoice_untaxed_amount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-12-02 08:32+0000\n" +"PO-Revision-Date: 2022-12-02 08:32+0000\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: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__invoiced_untaxed_amount +msgid "Invoiced Untaxed Amount" +msgstr "Facturé HT" + +#. module: sale_order_invoice_untaxed_amount +#: model_terms:ir.ui.view,arch_db:sale_order_invoice_untaxed_amount.view_order_tree_invoiced_untaxed_amount +msgid "Invoiced Untaxed Total" +msgstr "Facturé HT Total" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model,name:sale_order_invoice_untaxed_amount.model_sale_order +msgid "Sales Order" +msgstr "Bon de commande" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__uninvoiced_untaxed_amount +msgid "Uninvoiced Untaxed Amount" +msgstr "Non-facturé HT" + +#. module: sale_order_invoice_untaxed_amount +#: model_terms:ir.ui.view,arch_db:sale_order_invoice_untaxed_amount.view_order_tree_invoiced_untaxed_amount +msgid "Uninvoiced Untaxed Total" +msgstr "Non-facturé HT Total" diff --git a/sale_order_invoice_untaxed_amount/i18n/sale_order_invoice_untaxed_amount.pot b/sale_order_invoice_untaxed_amount/i18n/sale_order_invoice_untaxed_amount.pot new file mode 100644 index 0000000..51939a2 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/i18n/sale_order_invoice_untaxed_amount.pot @@ -0,0 +1,56 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_order_invoice_untaxed_amount +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-12-02 08:33+0000\n" +"PO-Revision-Date: 2022-12-02 08:33+0000\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: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__display_name +msgid "Display Name" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__invoiced_untaxed_amount +msgid "Invoiced Untaxed Amount" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model_terms:ir.ui.view,arch_db:sale_order_invoice_untaxed_amount.view_order_tree_invoiced_untaxed_amount +msgid "Invoiced Untaxed Total" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order____last_update +msgid "Last Modified on" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model,name:sale_order_invoice_untaxed_amount.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model:ir.model.fields,field_description:sale_order_invoice_untaxed_amount.field_sale_order__uninvoiced_untaxed_amount +msgid "Uninvoiced Untaxed Amount" +msgstr "" + +#. module: sale_order_invoice_untaxed_amount +#: model_terms:ir.ui.view,arch_db:sale_order_invoice_untaxed_amount.view_order_tree_invoiced_untaxed_amount +msgid "Uninvoiced Untaxed Total" +msgstr "" diff --git a/sale_order_invoice_untaxed_amount/models/__init__.py b/sale_order_invoice_untaxed_amount/models/__init__.py new file mode 100644 index 0000000..6aacb75 --- /dev/null +++ b/sale_order_invoice_untaxed_amount/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/sale_order_invoice_untaxed_amount/models/sale_order.py b/sale_order_invoice_untaxed_amount/models/sale_order.py new file mode 100644 index 0000000..639253e --- /dev/null +++ b/sale_order_invoice_untaxed_amount/models/sale_order.py @@ -0,0 +1,43 @@ +# Copyright (C) 2021 ForgeFlow S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + + _inherit = "sale.order" + + invoiced_untaxed_amount = fields.Monetary( + string="Invoiced Untaxed Amount", + compute="_compute_invoice_untaxed_amount", + store=True, + ) + + uninvoiced_untaxed_amount = fields.Monetary( + string="Uninvoiced Untaxed Amount", + compute="_compute_invoice_untaxed_amount", + store=True, + ) + + @api.depends( + "state", + "invoice_ids", + "invoice_ids.amount_untaxed_signed", + "amount_total", + "invoice_ids.state", + ) + def _compute_invoice_untaxed_amount(self): + for rec in self: + if rec.state != "cancel" and rec.invoice_ids: + rec.invoiced_untaxed_amount = 0.0 + for invoice in rec.invoice_ids: + if invoice.state != "cancel": + rec.invoiced_untaxed_amount += invoice.amount_untaxed_signed + rec.uninvoiced_untaxed_amount = max(0, rec.amount_untaxed - rec.invoiced_untaxed_amount) + else: + rec.invoiced_untaxed_amount = 0.0 + if rec.state in ["draft", "sent", "cancel"]: + rec.uninvoiced_untaxed_amount = 0.0 + else: + rec.uninvoiced_untaxed_amount = rec.amount_untaxed diff --git a/sale_order_invoice_untaxed_amount/tests/__init__.py b/sale_order_invoice_untaxed_amount/tests/__init__.py new file mode 100644 index 0000000..7dd777d --- /dev/null +++ b/sale_order_invoice_untaxed_amount/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_order_invoice_untaxed_amount diff --git a/sale_order_invoice_untaxed_amount/tests/test_sale_order_invoice_untaxed_amount.py b/sale_order_invoice_untaxed_amount/tests/test_sale_order_invoice_untaxed_amount.py new file mode 100644 index 0000000..baebf0c --- /dev/null +++ b/sale_order_invoice_untaxed_amount/tests/test_sale_order_invoice_untaxed_amount.py @@ -0,0 +1,134 @@ +# Copyright (C) 2021 ForgeFlow S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) + +from odoo.tests import common + + +class TestSaleOrderInvoiceUntaxedAmount(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Partners + cls.res_partner_1 = cls.env["res.partner"].create({"name": "Wood Corner"}) + + # Products + cls.product_1 = cls.env["product.product"].create( + { + "name": "Desk Combination", + "type": "consu", + "invoice_policy": "order", + } + ) + cls.product_2 = cls.env["product.product"].create( + { + "name": "Conference Chair", + "type": "consu", + "invoice_policy": "order", + } + ) + cls.product_3 = cls.env["product.product"].create( + { + "name": "Repair Services", + "type": "service", + "invoice_policy": "order", + } + ) + + # Sale Order + cls.tax = cls.env["account.tax"].create( + {"name": "Tax 21", "type_tax_use": "sale", "amount": 21} + ) + cls.sale_order_1 = cls.env["sale.order"].create( + {"partner_id": cls.res_partner_1.id} + ) + cls.order_line_1 = cls.env["sale.order.line"].create( + { + "order_id": cls.sale_order_1.id, + "product_id": cls.product_1.id, + "product_uom": cls.product_1.uom_id.id, + "product_uom_qty": 10.0, + "price_unit": 10.0, + "tax_id": [(6, 0, cls.tax.ids)], + } + ) + cls.order_line_2 = cls.env["sale.order.line"].create( + { + "order_id": cls.sale_order_1.id, + "product_id": cls.product_2.id, + "product_uom": cls.product_2.uom_id.id, + "product_uom_qty": 25.0, + "price_unit": 4.0, + "tax_id": [(6, 0, cls.tax.ids)], + } + ) + cls.order_line_3 = cls.env["sale.order.line"].create( + { + "order_id": cls.sale_order_1.id, + "product_id": cls.product_3.id, + "product_uom": cls.product_3.uom_id.id, + "product_uom_qty": 20.0, + "price_unit": 5.0, + "tax_id": [(6, 0, cls.tax.ids)], + } + ) + # Total untaxed amount: 10*10 + 25*4 + 20*5 = 100 + 100 + 100 = 300 + + def test_untaxed_amount_without_invoice(self): + """No invoice yet: nothing invoiced, everything uninvoiced.""" + self.assertEqual(self.sale_order_1.amount_untaxed, 300.0) + self.assertEqual( + self.sale_order_1.invoiced_untaxed_amount, + 0.0, + "Invoiced Untaxed Amount should be 0.0 without invoice", + ) + # Order still in draft: uninvoiced amount is forced to 0.0 + self.assertEqual( + self.sale_order_1.uninvoiced_untaxed_amount, + 0.0, + "Uninvoiced Untaxed Amount should be 0.0 while in draft", + ) + + def test_untaxed_amount_with_down_payment(self): + """A fixed down payment of 100 is invoiced.""" + context_payment = { + "active_ids": [self.sale_order_1.id], + "active_id": self.sale_order_1.id, + } + payment = ( + self.env["sale.advance.payment.inv"] + .with_context(context_payment) + .create({"advance_payment_method": "fixed", "fixed_amount": 100}) + ) + payment.create_invoices() + + # The fixed down payment (100) is a tax-included amount. + # With a 21% tax, the invoiced untaxed amount is 100 / 1.21 = 82.64. + self.assertAlmostEqual( + self.sale_order_1.invoiced_untaxed_amount, + 82.64, + places=2, + msg="Invoiced Untaxed Amount should be 82.64 (100 tax included / 1.21)", + ) + self.assertAlmostEqual( + self.sale_order_1.uninvoiced_untaxed_amount, + 217.36, + places=2, + msg="Uninvoiced Untaxed Amount should be 217.36 (300 - 82.64)", + ) + + def test_untaxed_amount_fully_invoiced(self): + """The whole order is invoiced: nothing remains uninvoiced.""" + self.sale_order_1.action_confirm() + self.sale_order_1._create_invoices(final=True) + + self.assertEqual( + self.sale_order_1.invoiced_untaxed_amount, + 300.0, + "Invoiced Untaxed Amount should equal the full untaxed total", + ) + self.assertEqual( + self.sale_order_1.uninvoiced_untaxed_amount, + 0.0, + "Uninvoiced Untaxed Amount should be 0 once fully invoiced", + ) diff --git a/sale_order_invoice_untaxed_amount/views/sale_order_view.xml b/sale_order_invoice_untaxed_amount/views/sale_order_view.xml new file mode 100644 index 0000000..d17433c --- /dev/null +++ b/sale_order_invoice_untaxed_amount/views/sale_order_view.xml @@ -0,0 +1,31 @@ + + + + sale.order.form.invoiced.untaxed.amount + sale.order + + + + + + + + + + + + sale.order.list.invoiced.untaxed.amount + sale.order + + + + + + + + + \ No newline at end of file