Migrate account_credit_hold to 18.0

Squashed commit of the following:

commit 5adce1fd15
Author: Marc Durepos <marc@bemade.org>
Date:   Thu Sep 4 12:33:00 2025 -0400

    [MIG] 17.0..18.0 account_credit_hold

    Migrated module account_credit_hold from 17.0 to 18.0. Fixed views and
    models and wrote test cases to check functionality.

commit 91c52af6f4
Author: Marc Durepos <marc@bemade.org>
Date:   Thu Sep 4 11:38:37 2025 -0400

    Initial migration of account_credit_hold for testing in 18.0
This commit is contained in:
Marc Durepos 2025-09-04 12:34:47 -04:00
parent 6ea4b324b3
commit 06584b381a
15 changed files with 674 additions and 0 deletions

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,20 @@
{
"name": "Account Credit Hold",
"version": "18.0.1.1.1",
"summary": "Allows setting clients on credit hold, blocking the ability confirm a new sales order.",
"category": "Accounting/Accounting",
"author": "Bemade Inc.",
"maintainer": "Marc Durepos <marc@bemade.org>",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["sale", "account_followup", "stock"],
"data": [
"views/account_followup_views.xml",
"views/sale_order_views.xml",
"views/res_partner_views.xml",
"views/stock_picking_views.xml",
],
"demo": [],
"installable": True,
"auto_install": False,
}

View file

@ -0,0 +1,5 @@
from . import account_followup
from . import res_partner
from . import sale_order
from . import stock_picking
from . import account_followup_report

View file

@ -0,0 +1,8 @@
from odoo import fields, models, api
class FollowupLine(models.Model):
_inherit = 'account_followup.followup.line'
account_hold = fields.Boolean(string="Place on Credit Hold",
help="Place clients on account hold, restricting confirmation of new orders.")

View file

@ -0,0 +1,16 @@
from odoo import models, fields, api, _
class FollowUpReport(models.AbstractModel):
_inherit = 'account.followup.report'
def _get_followup_report_options(self, partner, options=None):
"""
Override to include credit hold information in followup report options.
"""
res = super()._get_followup_report_options(partner, options)
res.update({
'credit_hold': partner.followup_line_id.account_hold if partner.followup_line_id else False,
'partner_on_hold': partner.on_hold
})
return res

View file

@ -0,0 +1,108 @@
from odoo import fields, models, api, _
from datetime import date
class Partner(models.Model):
_inherit = "res.partner"
postpone_hold_until = fields.Date(
string="Postpone Hold",
help="Grace period specific to this partner despite unpaid invoices.",
tracking=True,
)
hold_bg = fields.Boolean(
string="Hold (technical)",
compute="_compute_hold_bg",
store=True,
default=False,
compute_sudo=True,
tracking=True,
)
on_hold = fields.Boolean(
string="Account on Hold",
help="Client account is on hold for unpaid overdue invoices.",
compute="_compute_on_hold",
compute_sudo=True,
)
@api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.hold_bg")
def _compute_on_hold(self):
for rec in self:
# If the parent company is on hold, so are all its sub-contacts and subsidiaries
if rec.commercial_partner_id != rec and rec.commercial_partner_id.hold_bg:
if not (rec.commercial_partner_id.postpone_hold_until and rec.commercial_partner_id.postpone_hold_until > date.today()):
rec.on_hold = True
continue
# If there is no parent company or the parent is not on hold, we compute for ourselves
if rec.hold_bg and not (
rec.postpone_hold_until and rec.postpone_hold_until > date.today()
):
rec.on_hold = True
else:
rec.on_hold = False
@api.autovacuum
def _cleanup_expired_hold_postponements(self):
expired_holds = self.search([("postpone_hold_until", "<=", date.today())])
expired_holds.write({"postpone_hold_until": False})
def action_credit_hold(self):
for rec in self:
rec.hold_bg = True
rec.message_post(body=_("Placed on credit hold."))
def action_lift_credit_hold(self):
for rec in self:
rec.hold_bg = False
rec.message_post(body=_("Credit hold lifted."))
@api.model
def _get_first_followup_level(self):
return self.env["account_followup.followup.line"].search(
[("company_id", "parent_of", self.env.company.id)],
order="delay asc",
limit=1,
)
@api.depends("followup_status", "followup_line_id")
def _compute_hold_bg(self):
first_followup_level = self._get_first_followup_level()
for rec in self:
prev_hold_bg = rec.hold_bg
level = rec.followup_line_id
if rec.followup_status == "no_action_needed" and not level:
rec.hold_bg = False
else:
rec.hold_bg = prev_hold_bg
def _get_followup_report(self, options):
# Override to prevent hanging on PDF generation
# Just set minimal required options without generating the report
options.setdefault('attachment_ids', [])
options['report_attachment_id'] = False
def _execute_followup_partner(self, options=None):
# Check if we need to place on credit hold before expensive operations
should_hold = (
self.followup_status == "in_need_of_action" and
self.followup_line_id and
hasattr(self.followup_line_id, 'account_hold') and
self.followup_line_id.account_hold
)
# If this is just for credit hold and we don't need reports/emails, skip heavy operations
if options and options.get('credit_hold_only'):
if should_hold:
self.action_credit_hold()
return should_hold
# Otherwise run the full followup process
res = super()._execute_followup_partner(options)
# Apply credit hold after successful followup execution
if should_hold:
self.action_credit_hold()
return res

View file

@ -0,0 +1,16 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = "sale.order"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")
def action_confirm(self):
if any(self.mapped('client_on_hold')):
raise UserError(_("This client is on credit hold. No new orders can be confirmed until past-due invoices "
"are paid or the accounting team postpones the hold."))
return super().action_confirm()

View file

@ -0,0 +1,9 @@
from odoo import fields, models, api
class ModelName(models.Model):
_inherit = "stock.picking"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")

View file

@ -0,0 +1,40 @@
# Overview
This module adds the notion of placing clients on credit hold to the followup levels from the Odoo Enterprise
account_followup module. It adds an option to followup levels to mark clients matching the followup criteria as on
credit hold. This hold restricts the confirmation of new sales orders for these clients.
Accountant and admin users can set a date until which the account hold will be
postponed on a specific partner's form view. This effectively gives clients an extra
grace period, allowing orders to be confirmed until the period ends.
# Change Log
## 17.0.1.0.0 (2024-05-15)
Various modifications to adapt code to Odoo 17.0
## 15.0.2.0.0 (2023-05-04)
Complete remake of the module, making the "Credit Hold" an action that is either manually or
automatically triggered from the Accounting > Followup Reports section or by setting the automatic application field
on followup levels.
## 15.0.1.1.0 (2023-05-03)
Adds a ribbon to stock pickings for clients on hold, and therefore a dependency on stock.
## 15.0.1.0.2 (2023-05-03)
Fix to sale order view and sale order confirmation for clients not on hold.
## 15.0.1.0.1 (2023-05-03)
Fix clients on hold when status is "outstanding_invoices".
## 15.0.1.0.0 (2023-05-02) Initial Release
Initial release of the module, including a setting on follow-up levels to toggle placing on credit hold. Blocks
the confirmation of sales orders for clients on credit hold. Red "Credit Hold" banner appears on sales orders and
partner form view when a client is on credit hold. Credit hold can be postponed by setting the "Postpone Hold" field
on the partner form view.

View file

@ -0,0 +1 @@
from . import test_account_credit_hold

View file

@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from odoo.tests import common, tagged, Form
from odoo.exceptions import UserError
@tagged("post_install", "-at_install")
class TestAccountCreditHold(common.TransactionCase):
def setUp(self):
super().setUp()
# Create test partner
self.partner = self.env["res.partner"].create(
{
"name": "Test Customer",
"is_company": True,
"customer_rank": 1,
"email": "test@example.com",
}
)
# Try to find existing followup lines or create new ones with unique delays
self.followup_line = self.env["account_followup.followup.line"].search(
[("delay", "=", 15), ("company_id", "=", self.env.company.id)], limit=1
)
if not self.followup_line:
# Find a unique delay value
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 15
while delay in existing_delays:
delay += 1
self.followup_line = self.env["account_followup.followup.line"].create(
{
"name": "First Reminder",
"delay": delay,
"account_hold": True,
"send_email": True,
"company_id": self.env.company.id,
}
)
else:
# Update existing line to have account_hold
self.followup_line.account_hold = True
# Create followup line without credit hold
self.followup_line_no_hold = self.env["account_followup.followup.line"].search(
[("delay", "=", 30), ("company_id", "=", self.env.company.id)], limit=1
)
if not self.followup_line_no_hold:
# Find a unique delay value
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 30
while delay in existing_delays:
delay += 1
self.followup_line_no_hold = self.env[
"account_followup.followup.line"
].create(
{
"name": "Second Reminder",
"delay": delay,
"account_hold": False,
"send_email": True,
"company_id": self.env.company.id,
}
)
else:
# Update existing line to not have account_hold
self.followup_line_no_hold.account_hold = False
def test_credit_hold_basic_functionality(self):
"""Test basic credit hold functionality"""
# Initially partner should not be on hold
self.assertFalse(self.partner.on_hold)
self.assertFalse(self.partner.hold_bg)
# Place partner on credit hold
with Form(self.partner) as form:
form.record.action_credit_hold()
self.assertTrue(self.partner.hold_bg)
self.assertTrue(self.partner.on_hold)
# Lift credit hold
with Form(self.partner) as form:
form.record.action_lift_credit_hold()
self.assertFalse(self.partner.hold_bg)
self.assertFalse(self.partner.on_hold)
def test_postpone_hold_functionality(self):
"""Test postpone hold until functionality"""
# Place partner on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
self.assertTrue(self.partner.on_hold)
# Set postpone date to tomorrow
tomorrow = date.today() + timedelta(days=1)
self.partner.postpone_hold_until = tomorrow
# Partner should not be on hold due to postponement
self.assertFalse(self.partner.on_hold)
# Set postpone date to yesterday
yesterday = date.today() - timedelta(days=1)
self.partner.postpone_hold_until = yesterday
# Partner should be on hold again
self.assertTrue(self.partner.on_hold)
def test_commercial_partner_hold_inheritance(self):
"""Test that child contacts inherit hold status from commercial partner"""
# Create child contact
child_partner = self.env["res.partner"].create(
{
"name": "Child Contact",
"parent_id": self.partner.id,
"type": "contact",
}
)
# Place parent on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
# Child should also be on hold
self.assertTrue(child_partner.on_hold)
# Lift hold from parent
self.partner.action_lift_credit_hold()
# Child should no longer be on hold
self.assertFalse(child_partner.on_hold)
def test_sale_order_blocking(self):
"""Test that sale orders are blocked when customer is on credit hold"""
# Get or create a product for testing
product = self.env["product.product"].search([("type", "=", "consu")], limit=1)
if not product:
product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 100.0,
}
)
# Create a sale order
sale_order = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": product.id,
"product_uom_qty": 1,
"price_unit": 100.0,
},
)
],
}
)
# Should be able to confirm when not on hold
sale_order.action_confirm()
self.assertEqual(sale_order.state, "sale")
# Create another order and place customer on hold
sale_order2 = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": product.id,
"product_uom_qty": 1,
"price_unit": 100.0,
},
)
],
}
)
with Form(self.partner) as form:
form.record.action_credit_hold()
# Should raise error when trying to confirm
with self.assertRaises(UserError):
sale_order2.action_confirm()
def test_followup_integration(self):
"""Test integration with followup system"""
# Set partner to in_need_of_action status and assign followup line
self.partner.write(
{
"followup_status": "in_need_of_action",
"followup_line_id": self.followup_line.id,
}
)
# Execute followup - should place on hold
self.partner._execute_followup_partner()
self.assertTrue(self.partner.hold_bg)
# Test with followup line that doesn't have account_hold
self.partner.write(
{
"followup_line_id": self.followup_line_no_hold.id,
"hold_bg": False, # Reset hold status
}
)
# Execute followup - should not place on hold
self.partner._execute_followup_partner()
self.assertFalse(self.partner.hold_bg)
def test_followup_report_options(self):
"""Test that followup report includes credit hold information"""
# Set up partner with followup line
self.partner.write(
{
"followup_line_id": self.followup_line.id,
}
)
self.partner.action_credit_hold()
# Get followup report options
report = self.env["account.followup.report"]
options = report._get_followup_report_options(self.partner)
# Should include credit hold information
self.assertTrue(options.get("credit_hold"))
self.assertTrue(options.get("partner_on_hold"))
def test_cleanup_expired_hold_postponements(self):
"""Test automatic cleanup of expired hold postponements"""
# Set expired postponement date
expired_date = date.today() - timedelta(days=5)
self.partner.postpone_hold_until = expired_date
# Run cleanup
self.env["res.partner"]._cleanup_expired_hold_postponements()
# Postponement should be cleared
self.assertFalse(self.partner.postpone_hold_until)
def test_hold_bg_computation(self):
"""Test hold_bg field computation based on followup status"""
# Test with no_action_needed status
self.partner.write(
{
"followup_status": "no_action_needed",
"followup_line_id": False,
}
)
self.partner._compute_hold_bg()
self.assertFalse(self.partner.hold_bg)
# Test with followup status and line
self.partner.write(
{
"followup_status": "in_need_of_action",
"followup_line_id": self.followup_line.id,
"hold_bg": True, # Set initial state
}
)
self.partner._compute_hold_bg()
# Should preserve existing hold_bg value when there's a followup line
self.assertTrue(self.partner.hold_bg)
def test_stock_picking_credit_hold_display(self):
"""Test that stock pickings show credit hold status"""
# Get warehouse and its outgoing picking type
warehouse = self.env["stock.warehouse"].search([], limit=1)
picking_type = warehouse.out_type_id
# Create a stock picking
picking = self.env["stock.picking"].create(
{
"partner_id": self.partner.id,
"picking_type_id": picking_type.id,
"location_id": picking_type.default_location_src_id.id,
"location_dest_id": picking_type.default_location_dest_id.id,
}
)
# Initially should not show as on hold
self.assertFalse(picking.client_on_hold)
# Place partner on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
# Picking should now show as on hold
self.assertTrue(picking.client_on_hold)
def test_get_first_followup_level(self):
"""Test _get_first_followup_level method"""
first_level = self.partner._get_first_followup_level()
self.assertEqual(first_level, self.followup_line)
# Create an earlier followup level with unique delay
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 5
while delay in existing_delays:
delay += 1
earlier_line = self.env["account_followup.followup.line"].create(
{
"name": "Early Reminder",
"delay": delay,
"account_hold": False,
"company_id": self.env.company.id,
}
)
first_level = self.partner._get_first_followup_level()
self.assertEqual(first_level, earlier_line)

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="account_followup_followup_line_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.account_followup_line.form</field>
<field name="model">account_followup.followup.line</field>
<field
name="inherit_id"
ref="account_followup.view_account_followup_followup_line_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='send_email']" position="before">
<field name="account_hold" />
</xpath></field>
</record>
<record id="manual_reminder_view_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.manual_reminder.form.inherit</field>
<field name="model">account_followup.manual_reminder</field>
<field
name="inherit_id"
ref="account_followup.manual_reminder_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//footer" position="before">
<div class="alert alert-warning" role="alert" invisible="not partner_id.on_hold">
<strong>Credit Hold:</strong> This customer is currently on credit hold.
</div>
</xpath></field>
</record>
<record id="action_credit_hold" model="ir.actions.server">
<field name="name">action_credit_hold</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="binding_model_id" ref="base.model_res_partner" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">records.action_credit_hold()</field>
</record>
</odoo>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_partner_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.res_partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="hold_bg" />
<field invisible="True" name="on_hold" />
<widget
bg_color="bg-danger"
invisible="on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
<record id="view_partner_property_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.view_partner_property_form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form" />
<field name="arch" type="xml">
<xpath expr="//group[@name='banks']" position="before">
<group string="Credit Hold">
<field
groups="account.group_account_manager,account.group_account_user"
name="postpone_hold_until"
readonly="hold_bg == False and postpone_hold_until == False"
/>
</group>
</xpath></field>
</record>
</odoo>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="stock_picking_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.stock_picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form" />
<field eval="8" name="priority" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>