From 94b26f1121e73408155e1706a7445d8ce75ec4f8 Mon Sep 17 00:00:00 2001 From: Diego Vega Date: Tue, 8 Jul 2025 11:08:36 -0600 Subject: [PATCH 1/9] [ADD] partner_product_category_control --- .../__init__.py | 1 + .../__manifest__.py | 16 +++++++ .../models/__init__.py | 3 ++ .../models/account_move.py | 48 +++++++++++++++++++ .../models/product_category_credit.py | 44 +++++++++++++++++ .../models/res_partner.py | 16 +++++++ .../security/ir.model.access.csv | 2 + .../views/account_move_views.xml | 0 .../views/product_category_credit_views.xml | 24 ++++++++++ .../views/res_partner_views.xml | 15 ++++++ 10 files changed, 169 insertions(+) create mode 100644 partner_product_category_credit_control/__init__.py create mode 100644 partner_product_category_credit_control/__manifest__.py create mode 100644 partner_product_category_credit_control/models/__init__.py create mode 100644 partner_product_category_credit_control/models/account_move.py create mode 100644 partner_product_category_credit_control/models/product_category_credit.py create mode 100644 partner_product_category_credit_control/models/res_partner.py create mode 100644 partner_product_category_credit_control/security/ir.model.access.csv create mode 100644 partner_product_category_credit_control/views/account_move_views.xml create mode 100644 partner_product_category_credit_control/views/product_category_credit_views.xml create mode 100644 partner_product_category_credit_control/views/res_partner_views.xml diff --git a/partner_product_category_credit_control/__init__.py b/partner_product_category_credit_control/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/partner_product_category_credit_control/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/partner_product_category_credit_control/__manifest__.py b/partner_product_category_credit_control/__manifest__.py new file mode 100644 index 000000000..897102037 --- /dev/null +++ b/partner_product_category_credit_control/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "Partner Product Category Credit Control", + "version": "18.0.1.0.0", + "depends": ["contacts", "account"], + "author": "Tu Empresa", + "category": "Accounting", + "description": "Gestión de líneas de crédito por categoría de producto para contactos", + "data": [ + "security/ir.model.access.csv", + "views/res_partner_views.xml", + "views/product_category_credit_views.xml", + # "views/account_move_views.xml" + ], + "installable": True, + "auto_install": False, +} diff --git a/partner_product_category_credit_control/models/__init__.py b/partner_product_category_credit_control/models/__init__.py new file mode 100644 index 000000000..47ee6d9d1 --- /dev/null +++ b/partner_product_category_credit_control/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_category_credit +from . import res_partner +from . import account_move diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py new file mode 100644 index 000000000..b79ba1ccc --- /dev/null +++ b/partner_product_category_credit_control/models/account_move.py @@ -0,0 +1,48 @@ +from odoo import models +from odoo.exceptions import ValidationError + +class AccountMove(models.Model): + _inherit = "account.move" + + def action_post(self): + for record in self: + if record.partner_id and record.move_type == 'out_invoice': + for line in record.invoice_line_ids: + credit_line = self.env["product.category.credit"].search([ + ("partner_id", "=", record.partner_id.id), + ("category_id", "=", line.product_id.categ_id.id), + ("type", "=", "customer") + ], limit=1) + if credit_line: + invoices = self.env['account.move'].search([ + ("partner_id", "=", record.partner_id.id), + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]) + ]) + total = sum(inv.amount_residual for inv in invoices) + total += sum(line.price_subtotal for line in record.invoice_line_ids if line.product_id.categ_id == credit_line.category_id) + if total > credit_line.credit: + raise ValidationError(f"El cliente excede su línea de crédito para la categoría '{credit_line.category_id.name}'.") + + elif record.partner_id and record.move_type == 'in_invoice': + credit_line = record.partner_id.supplier_credit_id + if credit_line: + invoices = self.env['account.move'].search([ + ("partner_id", "=", record.partner_id.id), + ("move_type", "=", "in_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]) + ]) + total = sum(inv.amount_residual for inv in invoices) + record.amount_total + if total > credit_line.credit: + raise ValidationError("No se puede confirmar la factura porque se ha rebasado el crédito disponible con el proveedor.") + + result = super().action_post() + + all_credit_lines = self.env['product.category.credit'].search([]) + all_credit_lines._invalidate_cache() + all_credit_lines._compute_used_credit() + all_credit_lines._compute_available_credit() + + return result \ No newline at end of file diff --git a/partner_product_category_credit_control/models/product_category_credit.py b/partner_product_category_credit_control/models/product_category_credit.py new file mode 100644 index 000000000..ecd1fc4be --- /dev/null +++ b/partner_product_category_credit_control/models/product_category_credit.py @@ -0,0 +1,44 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class ProductCategoryCredit(models.Model): + _name = 'product.category.credit' + _description = 'Crédito por categoría de producto' + _order = 'partner_id, category_id' + + name = fields.Char("Clave de crédito", required=True) + partner_id = fields.Many2one("res.partner", string="Contacto", required=True, ondelete="cascade") + category_id = fields.Many2one("product.category", string="Categoría de producto") + credit = fields.Float("Monto autorizado") + type = fields.Selection([("customer", "Cliente"), ("supplier", "Proveedor")], required=True) + used_credit = fields.Float("Monto utilizado", compute="_compute_used_credit", store=True) + available_credit = fields.Float("Monto disponible", compute="_compute_available_credit", store=True) + + @api.depends("credit", "used_credit") + def _compute_available_credit(self): + for rec in self: + rec.available_credit = rec.credit - rec.used_credit + + @api.depends("partner_id", "category_id", "type") + def _compute_used_credit(self): + for rec in self: + domain = [('partner_id', '=', rec.partner_id.id), + ('state', '=', 'posted'), + ('payment_state', 'in', ['not_paid', 'partial'])] + if rec.type == 'customer': + domain += [('move_type', '=', 'out_invoice')] + elif rec.type == 'supplier': + domain += [('move_type', '=', 'in_invoice')] + invoices = self.env['account.move'].search(domain) + total = 0.0 + for inv in invoices: + for line in inv.invoice_line_ids: + if rec.type == 'supplier' or (line.product_id.categ_id == rec.category_id): + total += line.price_subtotal + rec.used_credit = total + + _sql_constraints = [ + ('unique_credit_per_category', + 'UNIQUE(partner_id, category_id, type)', + 'Solo puede existir una línea de crédito por categoría y tipo por contacto.') + ] diff --git a/partner_product_category_credit_control/models/res_partner.py b/partner_product_category_credit_control/models/res_partner.py new file mode 100644 index 000000000..a4c442be6 --- /dev/null +++ b/partner_product_category_credit_control/models/res_partner.py @@ -0,0 +1,16 @@ +from odoo import models, fields + +class ResPartner(models.Model): + _inherit = "res.partner" + + product_category_credit_ids = fields.One2many( + "product.category.credit", "partner_id", + string="Líneas de crédito (cliente)", + domain=[("type", "=", "customer")] + ) + + supplier_credit_id = fields.Many2one( + "product.category.credit", + string="Línea de crédito (proveedor)", + domain=[("type", "=", "supplier")] + ) diff --git a/partner_product_category_credit_control/security/ir.model.access.csv b/partner_product_category_credit_control/security/ir.model.access.csv new file mode 100644 index 000000000..d3d65d00c --- /dev/null +++ b/partner_product_category_credit_control/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_category_credit_user,product_category_credit_user,model_product_category_credit,base.group_user,1,1,1,1 diff --git a/partner_product_category_credit_control/views/account_move_views.xml b/partner_product_category_credit_control/views/account_move_views.xml new file mode 100644 index 000000000..e69de29bb diff --git a/partner_product_category_credit_control/views/product_category_credit_views.xml b/partner_product_category_credit_control/views/product_category_credit_views.xml new file mode 100644 index 000000000..bef9237b0 --- /dev/null +++ b/partner_product_category_credit_control/views/product_category_credit_views.xml @@ -0,0 +1,24 @@ + + + product.category.credit.list + product.category.credit + + + + + + + + + + + + + + Líneas de Crédito + product.category.credit + list,form + + + + diff --git a/partner_product_category_credit_control/views/res_partner_views.xml b/partner_product_category_credit_control/views/res_partner_views.xml new file mode 100644 index 000000000..6d776e086 --- /dev/null +++ b/partner_product_category_credit_control/views/res_partner_views.xml @@ -0,0 +1,15 @@ + + + res.partner.form.credit + res.partner + + + + + + + + + + + From b9220910d1d637d95f92df907ed900878b1a95ac Mon Sep 17 00:00:00 2001 From: Diego Vega Date: Tue, 8 Jul 2025 11:34:42 -0600 Subject: [PATCH 2/9] [DEL] Unwanted file --- .../views/account_move_views.xml | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 partner_product_category_credit_control/views/account_move_views.xml diff --git a/partner_product_category_credit_control/views/account_move_views.xml b/partner_product_category_credit_control/views/account_move_views.xml deleted file mode 100644 index e69de29bb..000000000 From b1e44b0a11dd98d70270f41604501ec0a6762c10 Mon Sep 17 00:00:00 2001 From: Diego Vega Date: Tue, 8 Jul 2025 12:07:21 -0600 Subject: [PATCH 3/9] [FIX] Pre-commit --- .../README.rst | 36 +++++++++ .../__manifest__.py | 6 +- .../models/account_move.py | 77 ++++++++++++------- .../models/product_category_credit.py | 57 +++++++++----- .../models/res_partner.py | 10 ++- .../views/product_category_credit_views.xml | 45 ++++++----- .../views/res_partner_views.xml | 33 ++++---- 7 files changed, 177 insertions(+), 87 deletions(-) create mode 100644 partner_product_category_credit_control/README.rst diff --git a/partner_product_category_credit_control/README.rst b/partner_product_category_credit_control/README.rst new file mode 100644 index 000000000..6b7bf5328 --- /dev/null +++ b/partner_product_category_credit_control/README.rst @@ -0,0 +1,36 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* CONTEXT.rst (optional, strongly recommended) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. + + +Automatic changelog generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`HISTORY.rst` can be auto generated using `towncrier `_. + +Just put towncrier compatible changelog fragments into `readme/newsfragments` +and the changelog file will be automatically generated and updated when a new fragment is added. + +Please refer to `towncrier` documentation to know more. + +NOTE: the changelog will be automatically generated when using `/ocabot merge $option`. +If you need to run it manually, refer to `OCA/maintainer-tools README `_. diff --git a/partner_product_category_credit_control/__manifest__.py b/partner_product_category_credit_control/__manifest__.py index 897102037..bac276d6e 100644 --- a/partner_product_category_credit_control/__manifest__.py +++ b/partner_product_category_credit_control/__manifest__.py @@ -2,14 +2,14 @@ "name": "Partner Product Category Credit Control", "version": "18.0.1.0.0", "depends": ["contacts", "account"], - "author": "Tu Empresa", + "author": "Open Source Integrators,Odoo Community Association (OCA)", "category": "Accounting", - "description": "Gestión de líneas de crédito por categoría de producto para contactos", + "license": "AGPL-3", + "website": "https://github.com/OCA/credit-control", "data": [ "security/ir.model.access.csv", "views/res_partner_views.xml", "views/product_category_credit_views.xml", - # "views/account_move_views.xml" ], "installable": True, "auto_install": False, diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py index b79ba1ccc..4e260293d 100644 --- a/partner_product_category_credit_control/models/account_move.py +++ b/partner_product_category_credit_control/models/account_move.py @@ -1,48 +1,73 @@ -from odoo import models +from odoo import _, models from odoo.exceptions import ValidationError + class AccountMove(models.Model): _inherit = "account.move" def action_post(self): for record in self: - if record.partner_id and record.move_type == 'out_invoice': + if record.partner_id and record.move_type == "out_invoice": for line in record.invoice_line_ids: - credit_line = self.env["product.category.credit"].search([ - ("partner_id", "=", record.partner_id.id), - ("category_id", "=", line.product_id.categ_id.id), - ("type", "=", "customer") - ], limit=1) - if credit_line: - invoices = self.env['account.move'].search([ + credit_line = self.env["product.category.credit"].search( + [ ("partner_id", "=", record.partner_id.id), - ("move_type", "=", "out_invoice"), - ("state", "=", "posted"), - ("payment_state", "in", ["not_paid", "partial"]) - ]) + ("category_id", "=", line.product_id.categ_id.id), + ("type", "=", "customer"), + ], + limit=1, + ) + if credit_line: + invoices = self.env["account.move"].search( + [ + ("partner_id", "=", record.partner_id.id), + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ] + ) total = sum(inv.amount_residual for inv in invoices) - total += sum(line.price_subtotal for line in record.invoice_line_ids if line.product_id.categ_id == credit_line.category_id) + total += sum( + line.price_subtotal + for line in record.invoice_line_ids + if line.product_id.categ_id == credit_line.category_id + ) if total > credit_line.credit: - raise ValidationError(f"El cliente excede su línea de crédito para la categoría '{credit_line.category_id.name}'.") + raise ValidationError( + f"El cliente excede su línea de crédito \ + para la categoría \ + '{credit_line.category_id.name}'." + ) - elif record.partner_id and record.move_type == 'in_invoice': + elif record.partner_id and record.move_type == "in_invoice": credit_line = record.partner_id.supplier_credit_id if credit_line: - invoices = self.env['account.move'].search([ - ("partner_id", "=", record.partner_id.id), - ("move_type", "=", "in_invoice"), - ("state", "=", "posted"), - ("payment_state", "in", ["not_paid", "partial"]) - ]) - total = sum(inv.amount_residual for inv in invoices) + record.amount_total + invoices = self.env["account.move"].search( + [ + ("partner_id", "=", record.partner_id.id), + ("move_type", "=", "in_invoice"), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ] + ) + total = ( + sum(inv.amount_residual for inv in invoices) + + record.amount_total + ) if total > credit_line.credit: - raise ValidationError("No se puede confirmar la factura porque se ha rebasado el crédito disponible con el proveedor.") + raise ValidationError( + _( + "No se puede confirmar la factura porque \ + se ha rebasado el crédito disponible \ + con el proveedor." + ) + ) result = super().action_post() - all_credit_lines = self.env['product.category.credit'].search([]) + all_credit_lines = self.env["product.category.credit"].search([]) all_credit_lines._invalidate_cache() all_credit_lines._compute_used_credit() all_credit_lines._compute_available_credit() - return result \ No newline at end of file + return result diff --git a/partner_product_category_credit_control/models/product_category_credit.py b/partner_product_category_credit_control/models/product_category_credit.py index ecd1fc4be..881e44baf 100644 --- a/partner_product_category_credit_control/models/product_category_credit.py +++ b/partner_product_category_credit_control/models/product_category_credit.py @@ -1,18 +1,26 @@ -from odoo import models, fields, api -from odoo.exceptions import ValidationError +from odoo import api, fields, models + class ProductCategoryCredit(models.Model): - _name = 'product.category.credit' - _description = 'Crédito por categoría de producto' - _order = 'partner_id, category_id' + _name = "product.category.credit" + _description = "Crédito por categoría de producto" + _order = "partner_id, category_id" name = fields.Char("Clave de crédito", required=True) - partner_id = fields.Many2one("res.partner", string="Contacto", required=True, ondelete="cascade") + partner_id = fields.Many2one( + "res.partner", string="Contacto", required=True, ondelete="cascade" + ) category_id = fields.Many2one("product.category", string="Categoría de producto") credit = fields.Float("Monto autorizado") - type = fields.Selection([("customer", "Cliente"), ("supplier", "Proveedor")], required=True) - used_credit = fields.Float("Monto utilizado", compute="_compute_used_credit", store=True) - available_credit = fields.Float("Monto disponible", compute="_compute_available_credit", store=True) + type = fields.Selection( + [("customer", "Cliente"), ("supplier", "Proveedor")], required=True + ) + used_credit = fields.Float( + "Monto utilizado", compute="_compute_used_credit", store=True + ) + available_credit = fields.Float( + "Monto disponible", compute="_compute_available_credit", store=True + ) @api.depends("credit", "used_credit") def _compute_available_credit(self): @@ -22,23 +30,30 @@ def _compute_available_credit(self): @api.depends("partner_id", "category_id", "type") def _compute_used_credit(self): for rec in self: - domain = [('partner_id', '=', rec.partner_id.id), - ('state', '=', 'posted'), - ('payment_state', 'in', ['not_paid', 'partial'])] - if rec.type == 'customer': - domain += [('move_type', '=', 'out_invoice')] - elif rec.type == 'supplier': - domain += [('move_type', '=', 'in_invoice')] - invoices = self.env['account.move'].search(domain) + domain = [ + ("partner_id", "=", rec.partner_id.id), + ("state", "=", "posted"), + ("payment_state", "in", ["not_paid", "partial"]), + ] + if rec.type == "customer": + domain += [("move_type", "=", "out_invoice")] + elif rec.type == "supplier": + domain += [("move_type", "=", "in_invoice")] + invoices = self.env["account.move"].search(domain) total = 0.0 for inv in invoices: for line in inv.invoice_line_ids: - if rec.type == 'supplier' or (line.product_id.categ_id == rec.category_id): + if rec.type == "supplier" or ( + line.product_id.categ_id == rec.category_id + ): total += line.price_subtotal rec.used_credit = total _sql_constraints = [ - ('unique_credit_per_category', - 'UNIQUE(partner_id, category_id, type)', - 'Solo puede existir una línea de crédito por categoría y tipo por contacto.') + ( + "unique_credit_per_category", + "UNIQUE(partner_id, category_id, type)", + "Solo puede existir una línea de crédito por \ + categoría y tipo por contacto.", + ) ] diff --git a/partner_product_category_credit_control/models/res_partner.py b/partner_product_category_credit_control/models/res_partner.py index a4c442be6..ea773f6fa 100644 --- a/partner_product_category_credit_control/models/res_partner.py +++ b/partner_product_category_credit_control/models/res_partner.py @@ -1,16 +1,18 @@ -from odoo import models, fields +from odoo import fields, models + class ResPartner(models.Model): _inherit = "res.partner" product_category_credit_ids = fields.One2many( - "product.category.credit", "partner_id", + "product.category.credit", + "partner_id", string="Líneas de crédito (cliente)", - domain=[("type", "=", "customer")] + domain=[("type", "=", "customer")], ) supplier_credit_id = fields.Many2one( "product.category.credit", string="Línea de crédito (proveedor)", - domain=[("type", "=", "supplier")] + domain=[("type", "=", "supplier")], ) diff --git a/partner_product_category_credit_control/views/product_category_credit_views.xml b/partner_product_category_credit_control/views/product_category_credit_views.xml index bef9237b0..d49870cec 100644 --- a/partner_product_category_credit_control/views/product_category_credit_views.xml +++ b/partner_product_category_credit_control/views/product_category_credit_views.xml @@ -1,24 +1,29 @@ - - product.category.credit.list - product.category.credit - - - - - - - - - - - + + product.category.credit.list + product.category.credit + + + + + + + + + + + - - Líneas de Crédito - product.category.credit - list,form - + + Líneas de Crédito + product.category.credit + list,form + - + diff --git a/partner_product_category_credit_control/views/res_partner_views.xml b/partner_product_category_credit_control/views/res_partner_views.xml index 6d776e086..1330420da 100644 --- a/partner_product_category_credit_control/views/res_partner_views.xml +++ b/partner_product_category_credit_control/views/res_partner_views.xml @@ -1,15 +1,22 @@ - - res.partner.form.credit - res.partner - - - - - - - - - - + + res.partner.form.credit + res.partner + + + + + + + + + + From 16f63f871b9a11ffd8662c06ece4bb179ab15d92 Mon Sep 17 00:00:00 2001 From: Diego Vega Date: Tue, 8 Jul 2025 12:08:59 -0600 Subject: [PATCH 4/9] [FIX] pre-commit --- partner_product_category_credit_control/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 partner_product_category_credit_control/pyproject.toml diff --git a/partner_product_category_credit_control/pyproject.toml b/partner_product_category_credit_control/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/partner_product_category_credit_control/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" From 29dad0cfb0d2f67865ff550f6401944ad820f6a0 Mon Sep 17 00:00:00 2001 From: Diego Vega Date: Tue, 8 Jul 2025 14:54:53 -0600 Subject: [PATCH 5/9] [FIX] Update translations to English for credit control --- .../models/account_move.py | 12 +++++------ .../models/product_category_credit.py | 20 +++++++++---------- .../models/res_partner.py | 4 ++-- .../views/product_category_credit_views.xml | 4 ++-- .../views/res_partner_views.xml | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py index 4e260293d..3631791b4 100644 --- a/partner_product_category_credit_control/models/account_move.py +++ b/partner_product_category_credit_control/models/account_move.py @@ -34,9 +34,9 @@ def action_post(self): ) if total > credit_line.credit: raise ValidationError( - f"El cliente excede su línea de crédito \ - para la categoría \ - '{credit_line.category_id.name}'." + _(f"The customer has exceeded their credit limit \ + for the category \ + '{credit_line.category_id.name}'.") ) elif record.partner_id and record.move_type == "in_invoice": @@ -57,9 +57,9 @@ def action_post(self): if total > credit_line.credit: raise ValidationError( _( - "No se puede confirmar la factura porque \ - se ha rebasado el crédito disponible \ - con el proveedor." + "Cannot confirm the invoice because the \ + available credit with the vendor has \ + been exceeded." ) ) diff --git a/partner_product_category_credit_control/models/product_category_credit.py b/partner_product_category_credit_control/models/product_category_credit.py index 881e44baf..a5c6ff19f 100644 --- a/partner_product_category_credit_control/models/product_category_credit.py +++ b/partner_product_category_credit_control/models/product_category_credit.py @@ -3,23 +3,23 @@ class ProductCategoryCredit(models.Model): _name = "product.category.credit" - _description = "Crédito por categoría de producto" + _description = "Credit by Product Category" _order = "partner_id, category_id" - name = fields.Char("Clave de crédito", required=True) + name = fields.Char("Credit Key", required=True) partner_id = fields.Many2one( - "res.partner", string="Contacto", required=True, ondelete="cascade" + "res.partner", string="Contact", required=True, ondelete="cascade" ) - category_id = fields.Many2one("product.category", string="Categoría de producto") - credit = fields.Float("Monto autorizado") + category_id = fields.Many2one("product.category", string="Product Category") + credit = fields.Float("Authorized Amount") type = fields.Selection( - [("customer", "Cliente"), ("supplier", "Proveedor")], required=True + [("customer", "Customer"), ("supplier", "Supplier")], required=True ) used_credit = fields.Float( - "Monto utilizado", compute="_compute_used_credit", store=True + "Used Amount", compute="_compute_used_credit", store=True ) available_credit = fields.Float( - "Monto disponible", compute="_compute_available_credit", store=True + "Available Amount", compute="_compute_available_credit", store=True ) @api.depends("credit", "used_credit") @@ -53,7 +53,7 @@ def _compute_used_credit(self): ( "unique_credit_per_category", "UNIQUE(partner_id, category_id, type)", - "Solo puede existir una línea de crédito por \ - categoría y tipo por contacto.", + "Only one credit line per category and type \ + is allowed per contact.", ) ] diff --git a/partner_product_category_credit_control/models/res_partner.py b/partner_product_category_credit_control/models/res_partner.py index ea773f6fa..e11ced68c 100644 --- a/partner_product_category_credit_control/models/res_partner.py +++ b/partner_product_category_credit_control/models/res_partner.py @@ -7,12 +7,12 @@ class ResPartner(models.Model): product_category_credit_ids = fields.One2many( "product.category.credit", "partner_id", - string="Líneas de crédito (cliente)", + string="Credit Lines (Customer)", domain=[("type", "=", "customer")], ) supplier_credit_id = fields.Many2one( "product.category.credit", - string="Línea de crédito (proveedor)", + string="Credit Line (Supplier)", domain=[("type", "=", "supplier")], ) diff --git a/partner_product_category_credit_control/views/product_category_credit_views.xml b/partner_product_category_credit_control/views/product_category_credit_views.xml index d49870cec..970fa01ca 100644 --- a/partner_product_category_credit_control/views/product_category_credit_views.xml +++ b/partner_product_category_credit_control/views/product_category_credit_views.xml @@ -15,14 +15,14 @@ - Líneas de Crédito + Credit Lines product.category.credit list,form diff --git a/partner_product_category_credit_control/views/res_partner_views.xml b/partner_product_category_credit_control/views/res_partner_views.xml index 1330420da..19cd2f521 100644 --- a/partner_product_category_credit_control/views/res_partner_views.xml +++ b/partner_product_category_credit_control/views/res_partner_views.xml @@ -5,7 +5,7 @@ - + Date: Mon, 14 Jul 2025 14:05:48 -0600 Subject: [PATCH 6/9] [IMP] account_move.py: restrict only credit lines above 0 --- partner_product_category_credit_control/models/account_move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py index 3631791b4..fdda36435 100644 --- a/partner_product_category_credit_control/models/account_move.py +++ b/partner_product_category_credit_control/models/account_move.py @@ -17,7 +17,7 @@ def action_post(self): ], limit=1, ) - if credit_line: + if credit_line and credit_line.credit > 0: invoices = self.env["account.move"].search( [ ("partner_id", "=", record.partner_id.id), From aa34cd2f6bd0bd04eb54e41bfd541b7b27803797 Mon Sep 17 00:00:00 2001 From: "Samuel Macias Oropeza (smo)" Date: Thu, 17 Jul 2025 08:47:14 -0600 Subject: [PATCH 7/9] [FIX] Allow accounting administrators to publish the invoice through the restriction --- .../models/account_move.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py index fdda36435..66695ddcd 100644 --- a/partner_product_category_credit_control/models/account_move.py +++ b/partner_product_category_credit_control/models/account_move.py @@ -7,7 +7,11 @@ class AccountMove(models.Model): def action_post(self): for record in self: - if record.partner_id and record.move_type == "out_invoice": + if ( + not self.env.user.has_group("account.group_account_manager") + and record.partner_id + and record.move_type == "out_invoice" + ): for line in record.invoice_line_ids: credit_line = self.env["product.category.credit"].search( [ @@ -39,7 +43,11 @@ def action_post(self): '{credit_line.category_id.name}'.") ) - elif record.partner_id and record.move_type == "in_invoice": + elif ( + not self.env.user.has_group("account.group_account_manager") + and record.partner_id + and record.move_type == "in_invoice" + ): credit_line = record.partner_id.supplier_credit_id if credit_line: invoices = self.env["account.move"].search( From f7a365423d72f8dbaf093c808abdecca0ee9f334 Mon Sep 17 00:00:00 2001 From: Nikul-OSI Date: Mon, 24 Nov 2025 12:45:58 +0530 Subject: [PATCH 8/9] [ADD] partner_credit_average_day: Added on v18 --- partner_credit_average_day/README.rst | 171 ++++++ partner_credit_average_day/__init__.py | 3 + partner_credit_average_day/__manifest__.py | 19 + partner_credit_average_day/models/__init__.py | 4 + .../models/res_partner.py | 73 +++ .../models/sale_order.py | 89 +++ partner_credit_average_day/pyproject.toml | 3 + .../readme/CONFIGURE.md | 15 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 23 + partner_credit_average_day/readme/USAGE.md | 30 + .../security/groups.xml | 6 + .../static/description/index.html | 524 ++++++++++++++++++ partner_credit_average_day/tests/__init__.py | 3 + .../tests/test_credit_average.py | 136 +++++ .../views/res_partner_views.xml | 16 + 16 files changed, 1117 insertions(+) create mode 100644 partner_credit_average_day/README.rst create mode 100644 partner_credit_average_day/__init__.py create mode 100644 partner_credit_average_day/__manifest__.py create mode 100644 partner_credit_average_day/models/__init__.py create mode 100644 partner_credit_average_day/models/res_partner.py create mode 100644 partner_credit_average_day/models/sale_order.py create mode 100644 partner_credit_average_day/pyproject.toml create mode 100644 partner_credit_average_day/readme/CONFIGURE.md create mode 100644 partner_credit_average_day/readme/CONTRIBUTORS.md create mode 100644 partner_credit_average_day/readme/DESCRIPTION.md create mode 100644 partner_credit_average_day/readme/USAGE.md create mode 100644 partner_credit_average_day/security/groups.xml create mode 100644 partner_credit_average_day/static/description/index.html create mode 100644 partner_credit_average_day/tests/__init__.py create mode 100644 partner_credit_average_day/tests/test_credit_average.py create mode 100644 partner_credit_average_day/views/res_partner_views.xml diff --git a/partner_credit_average_day/README.rst b/partner_credit_average_day/README.rst new file mode 100644 index 000000000..48bdfe421 --- /dev/null +++ b/partner_credit_average_day/README.rst @@ -0,0 +1,171 @@ +========================== +Partner Credit Average Day +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0527759d5d2b6138f9a6a73577d3b68b2fd292c959313643c72a57985454bb5b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcredit--control-lightgray.png?logo=github + :target: https://github.com/OCA/credit-control/tree/18.0/partner_credit_average_day + :alt: OCA/credit-control +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/credit-control-18-0/credit-control-18-0-partner_credit_average_day + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/credit-control&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends customer credit management by introducing an +automatic credit check based on the average age of all open customer +invoices. + +Key Features +------------ + +- Adds the following fields to partners: + + - **Apply credit limit validation** — enables or disables checks. + - **Credit Limit (Days)** — maximum allowed average invoice age. + - **Average Credit Days** — computed field representing the average + age of outstanding posted customer invoices. + +- Enhances **Sale Order** confirmation: + + - Blocks confirmation when: + + - The user is *not* in the **Credit Manager** group, + - The partner has validation enabled, + - The partner’s average invoice age exceeds the configured limit. + + - Shows a detailed list of open invoices when blocking. + +- Provides a **Credit Manager** group to bypass the rules. + +This module helps organizations enforce stricter credit control before +allowing further sales to customers with aged outstanding invoices. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +This module introduces partner-level configuration options that +determine whether a customer is allowed to confirm a sale order based on +the average age of their open invoices. + +To configure the feature: + +1. Go to **Contacts → Customers** and open any customer record. +2. Under the **Credit Control** section, configure: + + - **Apply credit limit validation** + Enable this option to activate the credit rules for this customer. + - **Credit Limit (Days)** + Set the maximum allowed average age (in days) of all posted + customer invoices that still have a residual amount. + +3. Ensure that the **Credit Manager** group contains the users who + should be allowed to bypass this credit check. + +Usage +===== + +This module is used to prevent confirming a Sale Order when a customer's +open invoices have an average age higher than a configured limit. + +1. Configure the Partner +~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Go to **Contacts → Customers**. +2. Open a customer. +3. Set: + + - **Apply credit limit validation** = enabled + - **Credit Limit (Days)** = the maximum allowed average age + +4. The computed **Average Credit Days** will display automatically. + +2. Create or Review Open Invoices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create customer invoices (or leave existing ones). +2. Post the invoices. +3. Ensure some invoices remain unpaid (residual > 0). + +3. Create a Sale Order +~~~~~~~~~~~~~~~~~~~~~~ + +1. Create a new Sale Order for this customer. +2. Add products and confirm. + +4. Resulting Behavior +~~~~~~~~~~~~~~~~~~~~~ + +- If the customer exceeds the allowed average credit age: + + - **Non-Credit Manager users** → confirmation is **blocked**. + - A popup error shows: + + - Customer name + - Average invoice age + - Allowed limit + - List of open invoices with dates, residuals, and ages + +- If the user belongs to **Credit Manager**, the sale confirms normally. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Open Source Integrators + +Contributors +------------ + +- Maxime Chambreuil +- Nikul Chaudhary + +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. + +This module is part of the `OCA/credit-control `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/partner_credit_average_day/__init__.py b/partner_credit_average_day/__init__.py new file mode 100644 index 000000000..c9460c8d7 --- /dev/null +++ b/partner_credit_average_day/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from . import models diff --git a/partner_credit_average_day/__manifest__.py b/partner_credit_average_day/__manifest__.py new file mode 100644 index 000000000..05101d16c --- /dev/null +++ b/partner_credit_average_day/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Partner Credit Average Day", + "version": "18.0.1.0.0", + "summary": """Block sale confirmation when partner average + open invoice age exceeds configured days""", + "category": "Accounting", + "author": "Open Source Integrators, Odoo Community Association (OCA)", + "maintainer": "Open Source Integrators", + "license": "LGPL-3", + "website": "https://github.com/OCA/credit-control", + "depends": ["sale", "account"], + "data": [ + "security/groups.xml", + "views/res_partner_views.xml", + ], + "installable": True, +} diff --git a/partner_credit_average_day/models/__init__.py b/partner_credit_average_day/models/__init__.py new file mode 100644 index 000000000..55ebc97e2 --- /dev/null +++ b/partner_credit_average_day/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from . import res_partner +from . import sale_order diff --git a/partner_credit_average_day/models/res_partner.py b/partner_credit_average_day/models/res_partner.py new file mode 100644 index 000000000..b174e8989 --- /dev/null +++ b/partner_credit_average_day/models/res_partner.py @@ -0,0 +1,73 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + credit_limit_exception = fields.Boolean( + string="Apply Credit Limit Validation", + default=True, + help="""When checked, the partner will be validated against + average credit days on order confirmation.""", + ) + credit_limit_day = fields.Integer( + string="Credit Limit (Days)", + help="Maximum allowed average age (in days) of open invoices for this partner.", + ) + credit_average_day = fields.Float( + string="Average Credit Days", + compute="_compute_credit_average_day", + store=False, + help="Computed average age (in days) of the partner open invoices.", + ) + + def _compute_credit_average_day(self): + """ + Compute the average age (in days) of all open customer invoices. + An invoice is considered when: + - move_type = 'out_invoice' + - state = 'posted' + - amount_residual > 0 (still unpaid or partially paid) + + The age of an invoice = today - invoice_date. + If invoice_date is missing, fallback to move.date or create_date. + """ + AccountMove = self.env["account.move"] + today = fields.Date.context_today(self) + for partner in self: + # Search unpaid posted customer invoices + moves = AccountMove.search( + [ + ("partner_id", "=", partner.id), + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("amount_residual", ">", 0), + ] + ) + ages = [] + + for move in moves: + # Determine invoice date using a safe fallback chain + invoice_date = ( + move.invoice_date + or move.date + or (move.create_date.date() if move.create_date else None) + ) + + if isinstance(invoice_date, str): + invoice_date = fields.Date.from_string(invoice_date) + + # If still no date found, skip invoice + if not invoice_date: + continue + + # Calculate age + age_days = (today - invoice_date).days + + # Avoid negative values if invoice date is in the future + ages.append(max(age_days, 0)) + + # Assign computed average or 0 if no invoices + partner.credit_average_day = (sum(ages) / len(ages)) if ages else 0.0 diff --git a/partner_credit_average_day/models/sale_order.py b/partner_credit_average_day/models/sale_order.py new file mode 100644 index 000000000..4b3ce1332 --- /dev/null +++ b/partner_credit_average_day/models/sale_order.py @@ -0,0 +1,89 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def action_confirm(self): + """ + Extend Sale Order confirmation to enforce customer credit rules: + - If `credit_limit_exception` is enabled on the partner, + - And user is NOT in the Credit Manager group, + - And partner's average invoice age exceeds the configured limit, + Then block confirmation and show a detailed list of open invoices. + """ + AccountMove = self.env["account.move"] + today = fields.Date.context_today(self) + + for order in self: + partner = order.partner_id + + if ( + not partner.credit_limit_exception + or self.env.user.has_group( + "partner_credit_average_day.group_credit_manager" + ) + or not partner.credit_limit_day + ): + continue + + avg_days = float(partner.credit_average_day or 0.0) + + # If average exceeds limit → BLOCK ORDER CONFIRMATION + if avg_days > float(partner.credit_limit_day): + # Fetch all open posted customer invoices + open_invoices = AccountMove.search( + [ + ("partner_id", "=", partner.id), + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("amount_residual", ">", 0), + ], + order="invoice_date asc", + ) + + # Header lines + message_lines = [ + _( + 'The customer "%(name)s" exceeds the allowed credit limit.\n' + "Average invoice age: %(avg).2f days\n" + "Allowed limit: %(limit)s days\n" + ) + % { + "name": partner.display_name, + "avg": avg_days, + "limit": partner.credit_limit_day, + }, + _("Open invoices:"), + "", + ] + + # Detailed invoice list + for inv in open_invoices: + invoice_date = ( + inv.invoice_date + or inv.date + or (inv.create_date.date() if inv.create_date else None) + ) + if isinstance(invoice_date, str): + invoice_date = fields.Date.from_string(invoice_date) + if not invoice_date: + continue + + age_days = (today - invoice_date).days + age_days = max(age_days, 0) + + message_lines.append( + f"- {inv.name or inv.display_name} | " + f"Date: {invoice_date} | " + f"Residual: {inv.amount_residual:.2f} | " + f"Age: {age_days} days" + ) + + # Raise compiled message + raise UserError("\n".join(message_lines)) + + return super().action_confirm() diff --git a/partner_credit_average_day/pyproject.toml b/partner_credit_average_day/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/partner_credit_average_day/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/partner_credit_average_day/readme/CONFIGURE.md b/partner_credit_average_day/readme/CONFIGURE.md new file mode 100644 index 000000000..faa14d542 --- /dev/null +++ b/partner_credit_average_day/readme/CONFIGURE.md @@ -0,0 +1,15 @@ +This module introduces partner-level configuration options that determine +whether a customer is allowed to confirm a sale order based on the +average age of their open invoices. + +To configure the feature: + +1. Go to **Contacts → Customers** and open any customer record. +2. Under the **Credit Control** section, configure: + - **Apply credit limit validation** + Enable this option to activate the credit rules for this customer. + - **Credit Limit (Days)** + Set the maximum allowed average age (in days) of all posted customer invoices + that still have a residual amount. +3. Ensure that the **Credit Manager** group contains the users + who should be allowed to bypass this credit check. diff --git a/partner_credit_average_day/readme/CONTRIBUTORS.md b/partner_credit_average_day/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..8d73f3916 --- /dev/null +++ b/partner_credit_average_day/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Maxime Chambreuil \<\> +- Nikul Chaudhary \<\> \ No newline at end of file diff --git a/partner_credit_average_day/readme/DESCRIPTION.md b/partner_credit_average_day/readme/DESCRIPTION.md new file mode 100644 index 000000000..9a959ab0e --- /dev/null +++ b/partner_credit_average_day/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +This module extends customer credit management by introducing an +automatic credit check based on the average age of all open customer +invoices. + +## Key Features + +- Adds the following fields to partners: + - **Apply credit limit validation** — enables or disables checks. + - **Credit Limit (Days)** — maximum allowed average invoice age. + - **Average Credit Days** — computed field representing the average age + of outstanding posted customer invoices. + +- Enhances **Sale Order** confirmation: + - Blocks confirmation when: + - The user is *not* in the **Credit Manager** group, + - The partner has validation enabled, + - The partner’s average invoice age exceeds the configured limit. + - Shows a detailed list of open invoices when blocking. + +- Provides a **Credit Manager** group to bypass the rules. + +This module helps organizations enforce stricter credit control before +allowing further sales to customers with aged outstanding invoices. diff --git a/partner_credit_average_day/readme/USAGE.md b/partner_credit_average_day/readme/USAGE.md new file mode 100644 index 000000000..59975b991 --- /dev/null +++ b/partner_credit_average_day/readme/USAGE.md @@ -0,0 +1,30 @@ +This module is used to prevent confirming a Sale Order when a customer's +open invoices have an average age higher than a configured limit. + +### 1. Configure the Partner +1. Go to **Contacts → Customers**. +2. Open a customer. +3. Set: + - **Apply credit limit validation** = enabled + - **Credit Limit (Days)** = the maximum allowed average age +4. The computed **Average Credit Days** will display automatically. + +### 2. Create or Review Open Invoices +1. Create customer invoices (or leave existing ones). +2. Post the invoices. +3. Ensure some invoices remain unpaid (residual > 0). + +### 3. Create a Sale Order +1. Create a new Sale Order for this customer. +2. Add products and confirm. + +### 4. Resulting Behavior +- If the customer exceeds the allowed average credit age: + - **Non-Credit Manager users** → confirmation is **blocked**. + - A popup error shows: + - Customer name + - Average invoice age + - Allowed limit + - List of open invoices with dates, residuals, and ages + +- If the user belongs to **Credit Manager**, the sale confirms normally. diff --git a/partner_credit_average_day/security/groups.xml b/partner_credit_average_day/security/groups.xml new file mode 100644 index 000000000..3d4b5a670 --- /dev/null +++ b/partner_credit_average_day/security/groups.xml @@ -0,0 +1,6 @@ + + + Credit Manager + + + diff --git a/partner_credit_average_day/static/description/index.html b/partner_credit_average_day/static/description/index.html new file mode 100644 index 000000000..1204a4912 --- /dev/null +++ b/partner_credit_average_day/static/description/index.html @@ -0,0 +1,524 @@ + + + + + +Partner Credit Average Day + + + +
+

Partner Credit Average Day

+ + +

Beta License: LGPL-3 OCA/credit-control Translate me on Weblate Try me on Runboat

+

This module extends customer credit management by introducing an +automatic credit check based on the average age of all open customer +invoices.

+
+

Key Features

+
    +
  • Adds the following fields to partners:
      +
    • Apply credit limit validation — enables or disables checks.
    • +
    • Credit Limit (Days) — maximum allowed average invoice age.
    • +
    • Average Credit Days — computed field representing the average +age of outstanding posted customer invoices.
    • +
    +
  • +
  • Enhances Sale Order confirmation:
      +
    • Blocks confirmation when:
        +
      • The user is not in the Credit Manager group,
      • +
      • The partner has validation enabled,
      • +
      • The partner’s average invoice age exceeds the configured limit.
      • +
      +
    • +
    • Shows a detailed list of open invoices when blocking.
    • +
    +
  • +
  • Provides a Credit Manager group to bypass the rules.
  • +
+

This module helps organizations enforce stricter credit control before +allowing further sales to customers with aged outstanding invoices.

+

Table of contents

+ +
+

Configuration

+

This module introduces partner-level configuration options that +determine whether a customer is allowed to confirm a sale order based on +the average age of their open invoices.

+

To configure the feature:

+
    +
  1. Go to Contacts → Customers and open any customer record.
  2. +
  3. Under the Credit Control section, configure:
      +
    • Apply credit limit validation +Enable this option to activate the credit rules for this customer.
    • +
    • Credit Limit (Days) +Set the maximum allowed average age (in days) of all posted +customer invoices that still have a residual amount.
    • +
    +
  4. +
  5. Ensure that the Credit Manager group contains the users who +should be allowed to bypass this credit check.
  6. +
+
+
+

Usage

+

This module is used to prevent confirming a Sale Order when a customer’s +open invoices have an average age higher than a configured limit.

+
+

1. Configure the Partner

+
    +
  1. Go to Contacts → Customers.
  2. +
  3. Open a customer.
  4. +
  5. Set:
      +
    • Apply credit limit validation = enabled
    • +
    • Credit Limit (Days) = the maximum allowed average age
    • +
    +
  6. +
  7. The computed Average Credit Days will display automatically.
  8. +
+
+
+

2. Create or Review Open Invoices

+
    +
  1. Create customer invoices (or leave existing ones).
  2. +
  3. Post the invoices.
  4. +
  5. Ensure some invoices remain unpaid (residual > 0).
  6. +
+
+
+

3. Create a Sale Order

+
    +
  1. Create a new Sale Order for this customer.
  2. +
  3. Add products and confirm.
  4. +
+
+
+

4. Resulting Behavior

+
    +
  • If the customer exceeds the allowed average credit age:
      +
    • Non-Credit Manager users → confirmation is blocked.
    • +
    • A popup error shows:
        +
      • Customer name
      • +
      • Average invoice age
      • +
      • Allowed limit
      • +
      • List of open invoices with dates, residuals, and ages
      • +
      +
    • +
    +
  • +
  • If the user belongs to Credit Manager, the sale confirms normally.
  • +
+
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+ +
+
+

Authors

+
    +
  • Open Source Integrators
  • +
+
+
+

Contributors

+ +
+
+

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.

+

This module is part of the OCA/credit-control project on GitHub.

+

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

+
+
+ + diff --git a/partner_credit_average_day/tests/__init__.py b/partner_credit_average_day/tests/__init__.py new file mode 100644 index 000000000..6196b7f6c --- /dev/null +++ b/partner_credit_average_day/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from . import test_credit_average diff --git a/partner_credit_average_day/tests/test_credit_average.py b/partner_credit_average_day/tests/test_credit_average.py new file mode 100644 index 000000000..bbf121c0e --- /dev/null +++ b/partner_credit_average_day/tests/test_credit_average.py @@ -0,0 +1,136 @@ +# Copyright 2025 Open Source Integrators +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from datetime import date, timedelta + +from odoo.exceptions import UserError + +from odoo.addons.sale.tests.common import SaleCommon + + +class TestPartnerCreditControl(SaleCommon): + def setUp(self): + super().setUp() + + # MODELS + self.Move = self.env["account.move"] + self.Sale = self.env["sale.order"] + + self.partner.write( + { + "credit_limit_exception": True, + "credit_limit_day": 10, + } + ) + + def _create_invoice(self, days_ago=10, amount=100): + """ + Create a posted invoice with a specific age and residual amount. + """ + invoice_date = date.today() - timedelta(days=days_ago) + + invoice = self.Move.create( + { + "move_type": "out_invoice", + "partner_id": self.partner.id, + "invoice_date": invoice_date, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Test Line", + "quantity": 1, + "price_unit": amount, + }, + ) + ], + } + ) + + invoice.action_post() + self.assertEqual(invoice.state, "posted") + self.assertTrue(invoice.amount_residual > 0) + + return invoice + + def test_compute_credit_average_day(self): + """ + Validate computed average invoice age. + """ + # Invoice ages: 5 days, 15 days → expected average = 10 + inv1 = self._create_invoice(days_ago=5) + self.assertTrue(inv1) + inv2 = self._create_invoice(days_ago=15) + self.assertTrue(inv2) + + self.partner._compute_credit_average_day() + self.assertAlmostEqual(self.partner.credit_average_day, 10.0, 2) + + def test_sale_order_confirm_blocked(self): + """ + Should block confirming the SO when average + age > limit and user is not Credit Manager. + """ + + # Invoice age 20 days → exceeds credit_limit_day=10 + self._create_invoice(days_ago=20) + + sale = self.Sale.create( + { + "partner_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 50, + }, + ) + ], + } + ) + + with self.assertRaises(UserError): + sale.action_confirm() + + def test_sale_order_confirm_allowed_for_manager(self): + """ + Should allow confirming the SO if the user is Credit Manager. + """ + + # Invoice age 20 days → exceeds credit limit + self._create_invoice(days_ago=20) + + sale = self.Sale.create( + { + "partner_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 50, + }, + ) + ], + } + ) + + # Add current user to Credit Manager group + group = self.env.ref("partner_credit_average_day.group_credit_manager") + self.env.user.groups_id = [(4, group.id)] + + # Should not raise UserError + sale.action_confirm() + + # State may not immediately become "sale" depending on workflow, + # but method should complete without raising an exception. + self.assertTrue(True) diff --git a/partner_credit_average_day/views/res_partner_views.xml b/partner_credit_average_day/views/res_partner_views.xml new file mode 100644 index 000000000..8712e7437 --- /dev/null +++ b/partner_credit_average_day/views/res_partner_views.xml @@ -0,0 +1,16 @@ + + + res.partner.form.credit.average + res.partner + + + + + + + + + + + + From fc0bdeadad8296c19087b492bd1ba46a3ebe0649 Mon Sep 17 00:00:00 2001 From: Hector del Reguero Date: Wed, 17 Dec 2025 10:39:51 -0600 Subject: [PATCH 9/9] Add changes to activate and deactivate credit control --- .../__manifest__.py | 1 + .../models/account_move.py | 17 ++++++++++++++++- .../views/account_move_views.xml | 12 ++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 partner_product_category_credit_control/views/account_move_views.xml diff --git a/partner_product_category_credit_control/__manifest__.py b/partner_product_category_credit_control/__manifest__.py index bac276d6e..90ae25ab6 100644 --- a/partner_product_category_credit_control/__manifest__.py +++ b/partner_product_category_credit_control/__manifest__.py @@ -10,6 +10,7 @@ "security/ir.model.access.csv", "views/res_partner_views.xml", "views/product_category_credit_views.xml", + "views/account_move_views.xml", ], "installable": True, "auto_install": False, diff --git a/partner_product_category_credit_control/models/account_move.py b/partner_product_category_credit_control/models/account_move.py index 66695ddcd..5797a2649 100644 --- a/partner_product_category_credit_control/models/account_move.py +++ b/partner_product_category_credit_control/models/account_move.py @@ -1,12 +1,27 @@ -from odoo import _, models +from odoo import _, fields, models from odoo.exceptions import ValidationError class AccountMove(models.Model): _inherit = "account.move" + credit_control_active = fields.Boolean( + string="Activate Credit Control", + default=True, + help="Enable or disable credit control for this invoice." + ) + + def toggle_credit_control_active(self): + """Toggle the credit_control_active field.""" + for record in self: + record.credit_control_active = not record.credit_control_active + def action_post(self): for record in self: + # Skip credit control if disabled for this invoice + if not record.credit_control_active: + continue + if ( not self.env.user.has_group("account.group_account_manager") and record.partner_id diff --git a/partner_product_category_credit_control/views/account_move_views.xml b/partner_product_category_credit_control/views/account_move_views.xml new file mode 100644 index 000000000..3a03dbe7c --- /dev/null +++ b/partner_product_category_credit_control/views/account_move_views.xml @@ -0,0 +1,12 @@ + + + account.move.form.credit.control + account.move + + + + + + + +