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 + + + + + + + + + + + + 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/__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..90ae25ab6 --- /dev/null +++ b/partner_product_category_credit_control/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Partner Product Category Credit Control", + "version": "18.0.1.0.0", + "depends": ["contacts", "account"], + "author": "Open Source Integrators,Odoo Community Association (OCA)", + "category": "Accounting", + "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/__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..5797a2649 --- /dev/null +++ b/partner_product_category_credit_control/models/account_move.py @@ -0,0 +1,96 @@ +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 + 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 and credit_line.credit > 0: + 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"The customer has exceeded their credit limit \ + for the category \ + '{credit_line.category_id.name}'.") + ) + + 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( + [ + ("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( + _( + "Cannot confirm the invoice because the \ + available credit with the vendor has \ + been exceeded." + ) + ) + + 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 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..a5c6ff19f --- /dev/null +++ b/partner_product_category_credit_control/models/product_category_credit.py @@ -0,0 +1,59 @@ +from odoo import api, fields, models + + +class ProductCategoryCredit(models.Model): + _name = "product.category.credit" + _description = "Credit by Product Category" + _order = "partner_id, category_id" + + name = fields.Char("Credit Key", required=True) + partner_id = fields.Many2one( + "res.partner", string="Contact", required=True, ondelete="cascade" + ) + category_id = fields.Many2one("product.category", string="Product Category") + credit = fields.Float("Authorized Amount") + type = fields.Selection( + [("customer", "Customer"), ("supplier", "Supplier")], required=True + ) + used_credit = fields.Float( + "Used Amount", compute="_compute_used_credit", store=True + ) + available_credit = fields.Float( + "Available Amount", 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)", + "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 new file mode 100644 index 000000000..e11ced68c --- /dev/null +++ b/partner_product_category_credit_control/models/res_partner.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + product_category_credit_ids = fields.One2many( + "product.category.credit", + "partner_id", + string="Credit Lines (Customer)", + domain=[("type", "=", "customer")], + ) + + supplier_credit_id = fields.Many2one( + "product.category.credit", + string="Credit Line (Supplier)", + domain=[("type", "=", "supplier")], + ) 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" 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..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 + + + + + + + + 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..970fa01ca --- /dev/null +++ b/partner_product_category_credit_control/views/product_category_credit_views.xml @@ -0,0 +1,29 @@ + + + product.category.credit.list + product.category.credit + + + + + + + + + + + + + + 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 new file mode 100644 index 000000000..19cd2f521 --- /dev/null +++ b/partner_product_category_credit_control/views/res_partner_views.xml @@ -0,0 +1,22 @@ + + + res.partner.form.credit + res.partner + + + + + + + + + + +