Skip to content

feat(overdue-limit): add the Time Based Overdue Limit for Sales Invoice and Sales Order - #3

Merged
FawazAlhafiz merged 14 commits into
version-15from
feat/time-based-overdue-limit
Nov 4, 2026
Merged

FawazAlhafiz merged 14 commits into
version-15from
feat/time-based-overdue-limit

Conversation

@FawazAlhafiz

Copy link
Copy Markdown
Collaborator

Summary

Adds the Time Based Overdue Limit: ERPNext's Credit Limit keyed on how long a customer's debt has been overdue instead of how large it is. A rule warns about or blocks new Sales Invoices and Sales Orders for a customer whose oldest overdue invoice has passed a number of days.

  • New doctypes: Time Based Overdue Limit, with the child table Time Based Overdue Limit Exempt Role. A rule names a Customer or Customer Group, an optional company (blank means every company), a threshold in days, one action and optional exempt roles. Only Accounts Manager and System Manager can edit rules.

  • One hook per action on Sales Invoice and Sales Order:

    Action Hook Runs on
    Warn on Save validate save, and submit too
    Block on Save before_save save only
    Block on Submit before_submit submit only
  • Which rules apply: the most specific level wins: the customer's own rules, else its group's, else the nearest ancestor group's. A level wins as soon as it has a rule of any action, and only then is the hook's action picked out.

  • The day count comes from the customer's oldest Sales Invoice in the document's company whose status is Overdue or Overdue and Discounted, so ERPNext decides what is overdue.

  • Exempt roles turn a block into a warning, rule by rule.

  • Never checked: credit notes, POS consolidated invoices, and anything written by migrate, a patch, an install or a data import.

  • Code layout: the decisions are plain functions in overdue_limit/limit.py, kept apart from the queries and from the thin check entry point.

Behaviour to know before merging

  • The scheduler must be enabled. ERPNext's daily update_invoice_status job is what turns an untouched invoice Overdue. On a site with the scheduler off, no rule fires.
  • Block on Save doesn't stop a submit. A draft saved before the customer went overdue can still be submitted; a Block on Submit rule is what stops that.
  • Only allocated payments lift a block. A payment or credit note that isn't allocated to the overdue invoice leaves the block in place. The customer's overall balance is not considered.
  • The desk shows only the refusal. When a save is refused, the browser doesn't show the warnings sent with it. An exempt user sees each hook's message, one under the other.

Deploying

Run bench --site <site> migrate to create the two doctypes, then restart the bench so the new hooks load.

Tests

All three suites ran on a local site:

  • overdue_limit/tests/test_limit.py: the decisions on plain rows. 12 tests, OK.
  • overdue_limit/tests/test_limit_integration.py: real saves and submits of both doctypes. 12 tests, OK, 1 skipped because the site has a single company.
  • doctype/time_based_overdue_limit/test_time_based_overdue_limit.py: the rule's own validation. 5 tests, OK.

Docs

  • docs/overdue-limit/: the README index, a user guide and a developer reference.
  • docs/development/testing.md: updated.

🤖 Generated with Claude Code

FawazAlhafiz and others added 14 commits November 3, 2026 10:57
A rule names one Customer or Customer Group, an optional company, a
day threshold and an action: Warn on Save, Block on Save or Block on
Submit. Only Accounts Manager and System Manager can edit rules, so
sales users cannot add their own role to the exempt list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s warn or block

The customer's oldest overdue Sales Invoice in the document's company
sets the day count. Only the most specific tier of rules applies: the
customer's own, else its group's, else the nearest ancestor group's.
A customer whose GL balance is zero or less is never checked, because
unallocated payments leave invoices outstanding.

Not wired into hooks.py yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frappe fills an empty Link field of a new document from the default
keyed by its fieldname, and every ERPNext site has a global company
default, so an every-company rule was silently saved as a rule for the
default company. The form still pre-fills the company; clearing it now
sticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sites with the KSA customizations make tax_category mandatory on
Customer, so get_or_create_customer failed there before any assertion
ran. On ims.local this takes the Letter of Credit suite from 17 errors
to 1 and the party statement integration suite from 7 to 5; what
remains in both is the site's cost-center rule on balance sheet
accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… saves

The overdue invoice is posted today and its due date moved 25 days back,
rather than backdated, so the suite still runs in January on CI, whose
fiscal year is the calendar year. The test Sales Invoice carries no
header cost center, as real invoices don't, because a payment copies it
onto balance sheet lines that some sites refuse one on.

make_reference_sales_order now wraps the new make_sales_order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e bug

The matrix goes from 24 cases to 12. Checked against 23 bugs injected
into limit.py, both versions catch 21. The old one missed an exemption
from one block rule lifting another, and a misspelt skip flag, which
its loop over SKIP_FLAGS could never catch. The new one misses only
two wording changes, which it no longer pins.

Dropped: cases restating one-liners (the day count, the role
intersection), empty-in-empty-out, and the message sentences. The
action table is now the reference doc's three-by-four table, tested
against rule_outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… customer balance

A payment or credit note that isn't allocated to the overdue invoice no longer lifts the block,
even when the customer owes nothing overall: it has to be matched to the invoice with Payment
Reconciliation. This also drops the GL Entry query the check ran before every warning or block.
The oldest overdue invoice is now found by status, Overdue or Overdue and Discounted, instead of
outstanding amount and due date, so what counts as overdue is ERPNext's decision. ERPNext's daily
update_invoice_status job sets that status, so the rules depend on the scheduler: on a site where
it is disabled, invoices stay Unpaid and no rule fires.

The integration fixture now turns its invoice Overdue by running that same job instead of only
backdating the due date.
Warn on Save runs at validate, Block on Save at before_save and Block on Submit at before_submit,
and each hook looks only at its own action's rules, so a rule does what its name says and nothing
more:

- Warn on Save also warns at submit, because Frappe runs validate there too.
- Block on Save no longer refuses the submit of a draft saved before the customer went overdue; a
  Block on Submit rule is what stops a submit.
- Block on Submit no longer warns at save.

The tier is still chosen before the action filter, so a customer's warning rule keeps shadowing
its group's block rule. Each message line now names the rule's action.
…apply

When a save is refused, the desk drops the warnings sent with it, so only the refusal shows; the
docs had said both appear. An exempt user sees each hook's message stacked in one dialog. Also
spell out that any rule makes a level win whatever its action, that exemption is per rule, and
that a block rule with no exempt roles blocks Administrator too.
@FawazAlhafiz
FawazAlhafiz merged commit 070db31 into version-15 Nov 4, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant