diff --git a/account_invoice_base_invoicing_mode/__init__.py b/account_invoice_base_invoicing_mode/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_invoice_base_invoicing_mode/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_invoice_base_invoicing_mode/__manifest__.py b/account_invoice_base_invoicing_mode/__manifest__.py new file mode 100644 index 00000000000..bbfa56d5d77 --- /dev/null +++ b/account_invoice_base_invoicing_mode/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Base Invoicing Mode", + "version": "13.0.1.0.0", + "summary": "Base module for handling multiple invoicing mode", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "queue_job", "sale"], + "data": ["views/res_partner.xml"], +} diff --git a/account_invoice_base_invoicing_mode/models/__init__.py b/account_invoice_base_invoicing_mode/models/__init__.py new file mode 100644 index 00000000000..fcce316ab83 --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/__init__.py @@ -0,0 +1,4 @@ +from . import account_invoice +from . import queue_job +from . import res_partner +from . import sale_order diff --git a/account_invoice_base_invoicing_mode/models/account_invoice.py b/account_invoice_base_invoicing_mode/models/account_invoice.py new file mode 100644 index 00000000000..7e548bf20ad --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/account_invoice.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + +from odoo.addons.queue_job.job import job, related_action + + +class AccountMove(models.Model): + _inherit = "account.move" + + @job(default_channel="root.invoice_validation") + @related_action(action="related_action_open_invoice") + def _validate_invoice(self): + return self.sudo().action_post() diff --git a/account_invoice_base_invoicing_mode/models/queue_job.py b/account_invoice_base_invoicing_mode/models/queue_job.py new file mode 100644 index 00000000000..2365261768d --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/queue_job.py @@ -0,0 +1,35 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, models + + +class QueueJob(models.Model): + _inherit = "queue.job" + + def related_action_open_invoice(self): + """Open a form view with the invoice related to the job.""" + self.ensure_one() + model_name = self.model_name + records = self.env[model_name].browse(self.record_ids).exists() + if not records: + return None + action = { + "name": _("Related Record"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": records._name, + } + if len(records) == 1: + action["res_id"] = records.id + else: + action.update( + { + "name": _("Related Records"), + "view_mode": "tree,form", + "view_id": "account.view_invoice_tree", + "domain": [("id", "in", records.ids)], + } + ) + return action diff --git a/account_invoice_base_invoicing_mode/models/res_partner.py b/account_invoice_base_invoicing_mode/models/res_partner.py new file mode 100644 index 00000000000..9b908fa45ad --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/res_partner.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection([("standard", "Standard")], default="standard") + one_invoice_per_order = fields.Boolean( + "One invoice per order", + default=False, + help="Do not group sale order into one invoice.", + ) diff --git a/account_invoice_base_invoicing_mode/models/sale_order.py b/account_invoice_base_invoicing_mode/models/sale_order.py new file mode 100644 index 00000000000..aa0f5832296 --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/sale_order.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + invoicing_mode = fields.Selection(related="partner_invoice_id.invoicing_mode") diff --git a/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst b/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst b/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..453a040bfc0 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This is a base module for implementing different invoicing mode for customers. +It adds a selection field `invoicing_mode` in the Accounting tab of the partner +with a default value (Odoo standard invoicing mode). +But it serves no purpose installed on its own. + +The following modules use it to install specific invoicing mode : + + * `account_invoice_mode_at_shipping` + * `account_invoice_mode_monthly` diff --git a/account_invoice_base_invoicing_mode/readme/ROADMAP.rst b/account_invoice_base_invoicing_mode/readme/ROADMAP.rst new file mode 100644 index 00000000000..170d52b69c2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +On the version 13.0 PR it has been discussed to rename the modules like this: + + * account_invoice_base_invoicing_mode -> partner_invoicing_mode + * account_invoice_mode_monthly -> partner_invoicing_mode_monthly + * account_invoice_mode_at_shipping -> partner_invoicing_mode_at_shipping + +It would be great to do it, during the version 14.0 migration. diff --git a/account_invoice_base_invoicing_mode/views/res_partner.xml b/account_invoice_base_invoicing_mode/views/res_partner.xml new file mode 100644 index 00000000000..db51c1d1d35 --- /dev/null +++ b/account_invoice_base_invoicing_mode/views/res_partner.xml @@ -0,0 +1,16 @@ + + + + view_partner_property_form_base_invoicing_mode + res.partner + + + + + + + + + + + diff --git a/account_invoice_mode_at_shipping/__init__.py b/account_invoice_mode_at_shipping/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/account_invoice_mode_at_shipping/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/account_invoice_mode_at_shipping/__manifest__.py b/account_invoice_mode_at_shipping/__manifest__.py new file mode 100644 index 00000000000..4cfb7f0063b --- /dev/null +++ b/account_invoice_mode_at_shipping/__manifest__.py @@ -0,0 +1,12 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Mode At Shipping", + "version": "13.0.1.0.0", + "summary": "Create invoices automatically when goods are shipped.", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "account_invoice_base_invoicing_mode", "queue_job", "stock"], +} diff --git a/account_invoice_mode_at_shipping/models/__init__.py b/account_invoice_mode_at_shipping/models/__init__.py new file mode 100644 index 00000000000..b87ddbd73f5 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_partner +from . import stock_move +from . import stock_picking diff --git a/account_invoice_mode_at_shipping/models/res_partner.py b/account_invoice_mode_at_shipping/models/res_partner.py new file mode 100644 index 00000000000..59a5a73f252 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/res_partner.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection(selection_add=([("at_shipping", "At Shipping")])) diff --git a/account_invoice_mode_at_shipping/models/stock_move.py b/account_invoice_mode_at_shipping/models/stock_move.py new file mode 100644 index 00000000000..a9f8f1ba041 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/stock_move.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _get_related_invoices(self): + """ Overridden from stock_account to return the customer invoices + related to this stock move. + """ + invoices = super()._get_related_invoices() + line_invoices = self.mapped("sale_line_id.order_id.invoice_ids").filtered( + lambda x: x.state == "posted" + ) + invoices |= line_invoices + return invoices diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py new file mode 100644 index 00000000000..84b6d67b17a --- /dev/null +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -0,0 +1,53 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, models + +from odoo.addons.queue_job.job import job + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def action_done(self): + res = super().action_done() + + for picking in self: + if picking._invoice_at_shipping(): + picking.with_delay()._invoicing_at_shipping() + return res + + def _invoice_at_shipping(self): + """Check if picking must be invoiced at shipping.""" + self.ensure_one() + return ( + self.picking_type_code == "outgoing" + and self.sale_id.partner_invoice_id.invoicing_mode == "at_shipping" + ) + + @job(default_channel="root.invoice_at_shipping") + def _invoicing_at_shipping(self): + self.ensure_one() + sales = self.env["sale.order"].browse() + # Filter out non invoicable sales order + for sale in self._get_sales_order_to_invoice(): + if sale._get_invoiceable_lines(): + sales |= sale + # Split invoice creation on partner sales grouping on invoice settings + sales_one_invoice_per_order = sales.filtered( + "partner_invoice_id.one_invoice_per_order" + ) + invoices = self.env["account.move"].browse() + if sales_one_invoice_per_order: + invoices |= sales_one_invoice_per_order._create_invoices(grouped=True) + sales_many_invoice_per_order = sales - sales_one_invoice_per_order + if sales_many_invoice_per_order: + invoices |= sales_many_invoice_per_order._create_invoices(grouped=False) + for invoice in invoices: + invoice.with_delay()._validate_invoice() + return invoices or _("Nothing to invoice.") + + def _get_sales_order_to_invoice(self): + return self.mapped("move_lines.sale_line_id.order_id").filtered( + lambda r: r._get_invoiceable_lines() + ) diff --git a/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst b/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst b/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..cfbffff5ae0 --- /dev/null +++ b/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module allows to select a `At shipping` invoicing mode for a customer. +It is based on `account_invoice_base_invoicing_mode`. +When this mode is selected the customer will be invoiced automatically on +delivery of the goods. diff --git a/account_invoice_mode_at_shipping/tests/__init__.py b/account_invoice_mode_at_shipping/tests/__init__.py new file mode 100644 index 00000000000..094b649436f --- /dev/null +++ b/account_invoice_mode_at_shipping/tests/__init__.py @@ -0,0 +1 @@ +from . import test_invoice_mode_at_shipping diff --git a/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py new file mode 100644 index 00000000000..4466513d65e --- /dev/null +++ b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py @@ -0,0 +1,78 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeAtShipping(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.partner = cls.env.ref("base.res_partner_1") + cls.product = cls.env.ref("product.product_delivery_01") + cls.so1 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + stock_location = cls.env.ref("stock.stock_location_stock") + inventory = cls.env["stock.inventory"].create( + { + "name": "Test Inventory", + "product_ids": [(6, 0, cls.product.ids)], + "state": "confirm", + "line_ids": [ + ( + 0, + 0, + { + "product_qty": 100, + "location_id": stock_location.id, + "product_id": cls.product.id, + "product_uom_id": cls.product.uom_id.id, + }, + ) + ], + } + ) + inventory.action_validate() + + def test_invoice_created_at_shipping(self): + """Check that an invoice is created when goods are shipped.""" + self.partner.invoicing_mode = "at_shipping" + self.so1.action_confirm() + for picking in self.so1.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.with_context(test_queue_job_no_delay=True).button_validate() + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(self.so1.invoice_ids.state, "posted") + + def test_invoice_not_created_at_shipping(self): + """Check that an invoice is not created when goods are shipped.""" + self.partner.invoicing_mode = "standard" + self.so1.action_confirm() + for picking in self.so1.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.button_validate() + self.assertEqual(len(self.so1.invoice_ids), 0) diff --git a/account_invoice_mode_monthly/__init__.py b/account_invoice_mode_monthly/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/account_invoice_mode_monthly/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/account_invoice_mode_monthly/__manifest__.py b/account_invoice_mode_monthly/__manifest__.py new file mode 100644 index 00000000000..43f24f6e71b --- /dev/null +++ b/account_invoice_mode_monthly/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Mode Monthly", + "version": "13.0.1.0.0", + "summary": "Create invoices automatically on a monthly basis.", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "account_invoice_base_invoicing_mode", "queue_job", "sale"], + "data": ["data/ir_cron.xml", "views/res_config_settings_views.xml"], +} diff --git a/account_invoice_mode_monthly/data/ir_cron.xml b/account_invoice_mode_monthly/data/ir_cron.xml new file mode 100644 index 00000000000..0410250612f --- /dev/null +++ b/account_invoice_mode_monthly/data/ir_cron.xml @@ -0,0 +1,18 @@ + + + + Generate Monthly Invoices + + + 1 + days + -1 + + + model.cron_generate_monthly_invoices() + + + diff --git a/account_invoice_mode_monthly/models/__init__.py b/account_invoice_mode_monthly/models/__init__.py new file mode 100644 index 00000000000..ea64a69d2e0 --- /dev/null +++ b/account_invoice_mode_monthly/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import res_partner +from . import sale_order diff --git a/account_invoice_mode_monthly/models/res_company.py b/account_invoice_mode_monthly/models/res_company.py new file mode 100644 index 00000000000..614a77c7cc3 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_company.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + invoicing_mode_monthly_day_todo = fields.Integer( + "Invoicing Day", + default="31", + help="Day of the month to execute the invoicing. For a number higher" + "than the number of days in a month, the invoicing will be" + "executed on the last day of the month.", + ) + invoicing_mode_monthly_last_execution = fields.Datetime( + string="Last execution", + help="Last execution of monthly invoicing.", + readonly=True, + ) diff --git a/account_invoice_mode_monthly/models/res_config_settings.py b/account_invoice_mode_monthly/models/res_config_settings.py new file mode 100644 index 00000000000..a2c560c73c9 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_config_settings.py @@ -0,0 +1,14 @@ +# Copyright 2020 Camptocamp SA +# 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" + + res_invoicing_mode_monthly_day_todo = fields.Integer( + related="company_id.invoicing_mode_monthly_day_todo", readonly=False + ) + invoicing_mode_monthly_last_execution = fields.Datetime( + related="company_id.invoicing_mode_monthly_last_execution", readonly=True + ) diff --git a/account_invoice_mode_monthly/models/res_partner.py b/account_invoice_mode_monthly/models/res_partner.py new file mode 100644 index 00000000000..99953595e59 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_partner.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection(selection_add=([("monthly", "Monthly")])) diff --git a/account_invoice_mode_monthly/models/sale_order.py b/account_invoice_mode_monthly/models/sale_order.py new file mode 100644 index 00000000000..dc104fa7868 --- /dev/null +++ b/account_invoice_mode_monthly/models/sale_order.py @@ -0,0 +1,106 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import api, models +from odoo.osv.expression import OR + +from odoo.addons.queue_job.job import job + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.model + def cron_generate_monthly_invoices(self): + """Cron called daily to check if monthly invoicing needs to be done.""" + company_ids = self._company_monthly_invoicing_today() + if company_ids: + self.generate_monthly_invoices(company_ids) + + @api.model + def generate_monthly_invoices(self, companies=None): + """Generate monthly invoices for customers who require that mode. + + Invoices will be generated by other jobs split for different customer + and different payment term. + """ + if not companies: + companies = self.company_id + saleorder_groups = self.read_group( + [ + ("invoicing_mode", "=", "monthly"), + ("invoice_status", "=", "to invoice"), + ("company_id", "in", companies.ids), + ], + ["partner_invoice_id"], + groupby=self._get_groupby_fields_for_monthly_invoicing(), + lazy=False, + ) + for saleorder_group in saleorder_groups: + saleorder_ids = self.search(saleorder_group["__domain"]).ids + self.with_delay()._generate_invoices_by_partner(saleorder_ids) + companies.write({"invoicing_mode_monthly_last_execution": datetime.now()}) + return saleorder_groups + + @api.model + def _get_groupby_fields_for_monthly_invoicing(self): + """Returns the sale order fields used to group them into jobs.""" + return ["partner_invoice_id", "payment_term_id"] + + @job(default_channel="root.invoice_monthly") + def _generate_invoices_by_partner(self, saleorder_ids, invoicing_mode="monthly"): + """Generate invoices for a group of sale order belonging to a customer. + """ + sales = ( + self.browse(saleorder_ids) + .exists() + .filtered(lambda r: r.invoice_status == "to invoice") + ) + if not sales: + return "No sale order found to invoice ?" + invoices = sales._create_invoices( + grouped=sales[0].partner_invoice_id.one_invoice_per_order, final=True + ) + for invoice in invoices: + invoice.with_delay()._validate_invoice() + return invoices + + @api.model + def _company_monthly_invoicing_today(self): + """Get company ids for which today is monthly invoicing day.""" + today = datetime.now() + first_day_this_month = today.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + first_day_last_month = first_day_this_month - relativedelta(months=1) + last_day_of_this_month = (today + relativedelta(day=31)).day + # Last month still not executed, it needs to be done + domain_last_month = [ + ("invoicing_mode_monthly_last_execution", "<", first_day_last_month), + ] + # Invoicing day is today or in the past and invoicing not yet done + domain_this_month = [ + "|", + ("invoicing_mode_monthly_last_execution", "<", first_day_this_month), + ("invoicing_mode_monthly_last_execution", "=", False), + ("invoicing_mode_monthly_day_todo", "<=", today.day), + ] + # Make sure non exisiting days are done at the end of the month + domain_last_day_of_month = [ + "|", + ("invoicing_mode_monthly_last_execution", "<", first_day_this_month), + ("invoicing_mode_monthly_last_execution", "=", False), + ("invoicing_mode_monthly_day_todo", ">", today.day), + ] + if today.day == last_day_of_this_month: + domain = OR( + [domain_last_month, domain_this_month, domain_last_day_of_month] + ) + else: + domain = OR([domain_last_month, domain_this_month]) + + return self.env["res.company"].search(domain) diff --git a/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst b/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_mode_monthly/readme/DESCRIPTION.rst b/account_invoice_mode_monthly/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..4e45ab48a76 --- /dev/null +++ b/account_invoice_mode_monthly/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module allows to select a monthly invoicing mode for a customer. +It is based on `account_invoice_base_invoicing_mode`. +When this mode is selected for a customer, the customer will be automatically +invoiced diff --git a/account_invoice_mode_monthly/readme/INSTALL.rst b/account_invoice_mode_monthly/readme/INSTALL.rst new file mode 100644 index 00000000000..e69de29bb2d diff --git a/account_invoice_mode_monthly/readme/ROADMAP.rst b/account_invoice_mode_monthly/readme/ROADMAP.rst new file mode 100644 index 00000000000..ec704cfa7ac --- /dev/null +++ b/account_invoice_mode_monthly/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +On the version 13.0 PR there is some discussion about changing how the +`_company_monthly_invoicing_today` works by storing the next invoicing day +to simplify the computation. +Would it be an improvment or not ? +I answered with this `comment `_ diff --git a/account_invoice_mode_monthly/tests/__init__.py b/account_invoice_mode_monthly/tests/__init__.py new file mode 100644 index 00000000000..bae1b469a28 --- /dev/null +++ b/account_invoice_mode_monthly/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_invoice_mode_monthly +from . import test_invoice_mode_monthly_is_it_today diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py new file mode 100644 index 00000000000..1096cbd59c9 --- /dev/null +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py @@ -0,0 +1,161 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import tools +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeMonthly(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.SaleOrder = cls.env["sale.order"] + cls.partner = cls.env.ref("base.res_partner_1") + cls.partner.invoicing_mode = "monthly" + cls.partner2 = cls.env.ref("base.res_partner_2") + cls.partner2.invoicing_mode = "monthly" + cls.product = cls.env.ref("product.product_delivery_01") + cls.pt1 = cls.env["account.payment.term"].create({"name": "Term Two"}) + cls.pt2 = cls.env["account.payment.term"].create({"name": "Term One"}) + cls.so1 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "payment_term_id": cls.pt1.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + # Lets give the saleorder the same partner and payment terms + cls.so2 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "payment_term_id": cls.pt1.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + cls.company = cls.so1.company_id + + stock_location = cls.env.ref("stock.stock_location_stock") + inventory = cls.env["stock.inventory"].create( + { + "name": "Test Inventory", + "product_ids": [(6, 0, cls.product.ids)], + "state": "confirm", + "line_ids": [ + ( + 0, + 0, + { + "product_qty": 100, + "location_id": stock_location.id, + "product_id": cls.product.id, + "product_uom_id": cls.product.uom_id.id, + }, + ) + ], + } + ) + inventory.action_validate() + + def deliver_invoice(self, sale_order): + sale_order.action_confirm() + for picking in sale_order.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.button_validate() + + def test_saleorder_with_different_mode_term(self): + """Check multiple sale order one partner diverse terms.""" + self.so1.payment_term_id = self.pt1.id + self.deliver_invoice(self.so1) + self.so2.payment_term_id = self.pt2.id + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + # Two invoices because the term are different + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + + def test_saleorder_grouped_in_invoice(self): + """Check multiple sale order grouped in one invoice""" + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + # Same invoice for both order + self.assertEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + + def test_split_invoice_by_sale_order(self): + """For same customer invoice 2 sales order separately.""" + self.partner.invoicing_mode = "monthly" + self.partner.one_invoice_per_order = True + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + # Two invoices as they must be split + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + self.assertEqual(self.so2.invoice_ids.state, "posted") + + def test_invoice_for_multiple_customer(self): + """Check two sale order for different customers.""" + self.partner.invoicing_mode = "monthly" + self.so2.partner_id = self.partner2 + self.so2.partner_invoice_id = self.partner2 + self.so2.partner_shipping_id = self.partner2 + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + self.assertEqual(self.so2.invoice_ids.state, "posted") diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py new file mode 100644 index 00000000000..d978d4336ef --- /dev/null +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py @@ -0,0 +1,63 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from freezegun import freeze_time + +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeMonthly(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + cls.SaleOrder = cls.env["sale.order"] + + def test_late_invoicing_for_last_month(self): + """Check that last month invoicing will be done if missed.""" + company = self.env.company + company.invoicing_mode_monthly_day_todo = 31 + company.invoicing_mode_monthly_last_execution = "2020-05-31" + self.assertTrue(self.env.company) + with freeze_time("2020-07-03"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) + company.invoicing_mode_monthly_last_execution = "2020-06-30" + with freeze_time("2020-07-03"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + + def test_selected_day_not_exist_in_month(self): + """Check on last day of the month invoicing is done. + + The day of invoicing selected could not exist in the current + month, but invoicing should still be executed on the last + day of the month. + """ + company = self.env.company + company.invoicing_mode_monthly_day_todo = 31 + company.invoicing_mode_monthly_last_execution = "2020-05-29" + self.assertTrue(self.env.company) + with freeze_time("2020-06-29"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + with freeze_time("2020-06-30"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) + + def test_no_invoicing_done_yet(self): + """Check when is the first monthly invoicing done. + + When monthly invoicing has never been done, it will not be run + for the previous month. + """ + company = self.env.company + company.invoicing_mode_monthly_day_todo = 15 + company.invoicing_mode_monthly_last_execution = None + self.assertTrue(self.env.company) + with freeze_time("2020-06-11"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + with freeze_time("2020-06-30"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) diff --git a/account_invoice_mode_monthly/views/res_config_settings_views.xml b/account_invoice_mode_monthly/views/res_config_settings_views.xml new file mode 100644 index 00000000000..dbe09556648 --- /dev/null +++ b/account_invoice_mode_monthly/views/res_config_settings_views.xml @@ -0,0 +1,43 @@ + + + + + res.config.settings + +
+

Invoicing Mode

+
+
+
+
+ Monthly Invoicing Options +
+
+
+
+
+
+
+
+
+
+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..881bd9f2643 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +freezegun diff --git a/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode b/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode new file mode 120000 index 00000000000..d28ad4fff5f --- /dev/null +++ b/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode @@ -0,0 +1 @@ +../../../../account_invoice_base_invoicing_mode \ No newline at end of file diff --git a/setup/account_invoice_base_invoicing_mode/setup.py b/setup/account_invoice_base_invoicing_mode/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_base_invoicing_mode/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping b/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping new file mode 120000 index 00000000000..46c28f4f492 --- /dev/null +++ b/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping @@ -0,0 +1 @@ +../../../../account_invoice_mode_at_shipping \ No newline at end of file diff --git a/setup/account_invoice_mode_at_shipping/setup.py b/setup/account_invoice_mode_at_shipping/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_mode_at_shipping/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly b/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly new file mode 120000 index 00000000000..ed7e033ae94 --- /dev/null +++ b/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly @@ -0,0 +1 @@ +../../../../account_invoice_mode_monthly \ No newline at end of file diff --git a/setup/account_invoice_mode_monthly/setup.py b/setup/account_invoice_mode_monthly/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_mode_monthly/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)