diff --git a/CLAUDE.md b/CLAUDE.md index 81644b1..6bbef0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ optima_payment/ optima_payment/doctype/ # this app's doctypes (bank_guarantee_bg, letter_of_credit, cheque_*) cheque/ # cheque GL + payment entry override logic purchases_bulk/ # Purchases Bulk Tool: pure decisions + voucher create/rollback (vouchers.py), form door (api.py) + overdue_limit/ # Time Based Overdue Limit: pure decisions + queries + check (limit.py), hooked from doc_events/ override/ # Python class / whitelist overrides tests/utils.py # SHARED test factories (import these, don't build docs inline) docs/ # developer + user documentation (start at docs/README.md) @@ -61,9 +62,10 @@ mandatory before submit. Issue **commission is never reversed** on return/cancel - `FrappeTestCase` (unittest), isolated by `frappe.db.rollback()` in `tearDown`. No mocking. - Most suites are live-DB integration tests. Code that keeps its decisions in plain functions - pairs two files instead: `test_.py` checks every branch with plain rows and writes nothing, - and `test__integration.py` covers only what a real save shows. Example: - `purchases_bulk/tests/`. + pairs two files instead: `test_.py` checks those decisions with plain rows and writes + nothing, and `test__integration.py` covers only what a real save shows. A matrix case must + name the plausible bug it catches; one-liners, the code's own constants and message wording get + none. Example: `overdue_limit/tests/`. - Build fixtures with the factories in `optima_payment/tests/utils.py` (`make_optima_payment_setting`, `make_bank_guarantee_bg`, `make_letter_of_credit`). They are defensive and work around this bench's KSA/ZATCA mandatory-field customizations. diff --git a/docs/README.md b/docs/README.md index 93b1c4b..ec02f29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Documentation is organized **by domain**. Each folder has its own index. | [Letter of Credit](letter-of-credit/) | Accountants, implementers & developers | [letter-of-credit/](letter-of-credit/README.md) | | [Purchases Bulk Tool](purchases-bulk-tool/) | Accountants, implementers & developers | [purchases-bulk-tool/](purchases-bulk-tool/README.md) | | [Statement of Account](party-statement/) | Accountants, implementers & developers | [party-statement/](party-statement/README.md) | +| [Time Based Overdue Limit](overdue-limit/) | Accountants, implementers & developers | [overdue-limit/](overdue-limit/README.md) | | [Setup framework](setup/) | Developers | [setup/](setup/README.md) | ## Cross-cutting @@ -23,5 +24,5 @@ Documentation is organized **by domain**. Each folder has its own index. ### Quick links by audience -- **Accountants / implementers:** [Cheque](cheque/user-guide.md) · [Bank Guarantee](bank-guarantee/user-guide.md) · [Letter of Credit](letter-of-credit/user-guide.md) · [Purchases Bulk Tool](purchases-bulk-tool/user-guide.md) · [Statement of Account](party-statement/user-guide.md) -- **Developers:** [Cheque](cheque/reference.md) · [Bank Guarantee](bank-guarantee/reference.md) · [Letter of Credit](letter-of-credit/reference.md) · [Purchases Bulk Tool](purchases-bulk-tool/reference.md) · [Statement of Account](party-statement/reference.md) · [Setup architecture](setup/architecture.md) · [Setup Q&A](setup/questions-and-answers.md) · [Testing](development/testing.md) +- **Accountants / implementers:** [Cheque](cheque/user-guide.md) · [Bank Guarantee](bank-guarantee/user-guide.md) · [Letter of Credit](letter-of-credit/user-guide.md) · [Purchases Bulk Tool](purchases-bulk-tool/user-guide.md) · [Statement of Account](party-statement/user-guide.md) · [Overdue Limit](overdue-limit/user-guide.md) +- **Developers:** [Cheque](cheque/reference.md) · [Bank Guarantee](bank-guarantee/reference.md) · [Letter of Credit](letter-of-credit/reference.md) · [Purchases Bulk Tool](purchases-bulk-tool/reference.md) · [Statement of Account](party-statement/reference.md) · [Overdue Limit](overdue-limit/reference.md) · [Setup architecture](setup/architecture.md) · [Setup Q&A](setup/questions-and-answers.md) · [Testing](development/testing.md) diff --git a/docs/development/testing.md b/docs/development/testing.md index c88b64a..cbcdb14 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -17,10 +17,19 @@ creates survives the test. assert on those Payment Entries (`get_lc_payment_entries`, filtered by the `is_lc_*` flags). See [letter-of-credit/reference.md](../letter-of-credit/reference.md). - **Purchases Bulk Tool** keeps its decisions in plain functions, so its tests come as a pair. - `test_vouchers.py` checks every branch with plain rows and writes nothing; it runs in + `test_vouchers.py` checks those decisions with plain rows and writes nothing; it runs in milliseconds and needs no data on the site. `test_vouchers_integration.py` asserts on the real vouchers (lines, header dates, docstatus) and the row status / reference stamps. See [purchases-bulk-tool/reference.md](../purchases-bulk-tool/reference.md). +- **Time Based Overdue Limit** pairs the same way. `test_limit.py` runs the rule precedence and how + triggered rules combine on plain rules; `test_limit_integration.py` saves and submits real Sales + Invoices and Sales Orders for a customer with an invoice 25 days overdue. See + [overdue-limit/reference.md](../overdue-limit/reference.md). + +A matrix case earns its place by naming the plausible bug it catches: how rules combine, a +boundary, a reachable edge case, or a spec table from the docs. A one-liner, a loop over the code's +own constant, and a message's wording get no case; assert which line a branch produces, not the +sentence. `overdue_limit/tests/test_limit.py` is the worked example. --- @@ -40,6 +49,12 @@ factories instead of building documents inline: | `get_or_create_purchase_item(company, item_code, is_stock_item=…)` | A purchase Item taxed by the suite's own Item Tax Template, its tax row carrying the test suppliers' tax category so that template applies; pass `is_stock_item=1` for Purchase Receipt tests | | `get_or_create_purchase_item_tax_template(company)` | The Item Tax Template behind those items, on the same account and rate | | `set_test_cost_center_default(company)` | Makes a leaf cost center the session user's `cost_center` default, so the tool's vouchers carry one on sites that make it mandatory on Purchase Invoice. It lives in the defaults cache: pair it with `frappe.clear_cache(user=frappe.session.user)` after the rollback | +| `make_sales_order(customer=…, do_not_insert=…, do_not_submit=…, **overrides)` | A one-line Sales Order on the test item, cost center and pre-seeded tax row; `make_reference_sales_order` wraps it | +| `make_sales_invoice(customer=…, posting_date=…, due_date=…, do_not_insert=…, do_not_submit=…, **overrides)` | A one-line Sales Invoice; a past `posting_date` is kept, and the due date defaults to it. The header has no cost center, as on real invoices, because a payment copies it onto balance sheet lines that some sites refuse one on | +| `make_invoice_overdue(invoice, days)` | Moves the invoice's due date and payment schedule `days` into the past, then runs ERPNext's `update_invoice_status` so the invoice turns Overdue as it would on a live site | +| `make_credit_note(invoice)` | An unsaved full return of the invoice, with the return reason some sites require | +| `make_invoice_payment(invoice, allocate=…)` | A submitted Payment Entry for the invoice's outstanding amount; `allocate=False` leaves it unallocated, so the invoice stays outstanding while the customer owes nothing | +| `make_overdue_limit(party=…, action=…, threshold_days=…, applies_to=…, company=…, exempt_roles=…, enabled=…)` | A Time Based Overdue Limit rule | The factories are defensive (`get_or_create_*`) and work around this bench's KSA/ZATCA customizations (mandatory Arabic name fields, tax category, item tax templates, order-level @@ -57,6 +72,8 @@ bench --site run-tests --module optima_payment.optima_payment.doctype.let bench --site run-tests --module optima_payment.optima_payment.doctype.bank_guarantee_bg.test_bank_guarantee_bg bench --site run-tests --module optima_payment.purchases_bulk.tests.test_vouchers bench --site run-tests --module optima_payment.purchases_bulk.tests.test_vouchers_integration +bench --site run-tests --module optima_payment.overdue_limit.tests.test_limit +bench --site run-tests --module optima_payment.overdue_limit.tests.test_limit_integration ``` The site needs `optima_payment` installed and `allow_tests` enabled diff --git a/docs/overdue-limit/README.md b/docs/overdue-limit/README.md new file mode 100644 index 0000000..1990da6 --- /dev/null +++ b/docs/overdue-limit/README.md @@ -0,0 +1,16 @@ +# Time Based Overdue Limit — Docs + +Everything about warning about or blocking sales to a customer whose invoices have been unpaid too +long past their due date. + +| Document | For | What it covers | +|----------|-----|----------------| +| [user-guide.md](user-guide.md) | Accountants & implementers | Setting up a rule, the three actions, which rule wins, how the days are counted, exempt roles, what is never checked, troubleshooting | +| [reference.md](reference.md) | Developers | Which hook checks each action, rule selection by tier, the day-count query, the outcome table, the blank company, known limits, tests | + +The feature is layered on **Time Based Overdue Limit**, with the child table **Time Based Overdue +Limit Exempt Role**. It checks standard **Sales Invoice** and **Sales Order** documents when they +are saved and submitted. It is ERPNext's Credit Limit keyed on how long the debt has been overdue +instead of how large it is. + +See also: [Testing](../development/testing.md) for how the suite is written and run. diff --git a/docs/overdue-limit/reference.md b/docs/overdue-limit/reference.md new file mode 100644 index 0000000..90d80ce --- /dev/null +++ b/docs/overdue-limit/reference.md @@ -0,0 +1,254 @@ +# Time Based Overdue Limit + +Developer + power-user reference for the **Time Based Overdue Limit** doctype: when its check runs +on Sales Invoices and Sales Orders, which rules apply, how the overdue days are counted, and which +hook checks each action. For a click-by-click guide aimed at accountants, see +[user-guide.md](user-guide.md). + +--- + +## What it is + +A credit-control rule keyed on the **age** of a customer's debt rather than its amount. It is +ERPNext's Credit Limit (`erpnext/selling/doctype/customer/customer.py`, `check_credit_limit`) with +the same shape: rules on Customer and Customer Group, a customer rule overriding its group's, and +a role that is let through. A rule names one party, an optional company, a day threshold and one +of three actions, and each action is checked at one hook of **Sales Invoice** and **Sales Order**: +Warn on Save at `validate`, Block on Save at `before_save`, Block on Submit at `before_submit`. It +warns with `frappe.msgprint` or blocks with `frappe.throw`, and needs no client script. + +The doctype is wholly owned by this app and adds no field to another app's doctype, so the +[setup framework](../setup/architecture.md) and its `sync()` patches are not involved. + +--- + +## Where the code lives + +The work is split into deciding and doing. The decisions are plain functions that take rows and +return values, with no database access. Everything that reads calls them. + +``` +optima_payment/overdue_limit/ +└── limit.py + ├── DECISIONS # pure: skipped documents, rule selection by tier and action, day count, + │ # exemption, the document's verdict + ├── MESSAGES # the warning / refusal text, one line per rule + ├── CHECK # check(doc, action): loads, decides, then msgprint or throw + └── QUERIES # group chain, enabled rules + exempt roles, oldest overdue invoice + +optima_payment/doc_events/overdue_limit.py # validate / before_save / before_submit → limit.check, one action each +optima_payment/hooks.py # doc_events for Sales Invoice and Sales Order + +optima_payment/optima_payment/doctype/ +├── time_based_overdue_limit/ # the rule; controller validates threshold and duplicates +└── time_based_overdue_limit_exempt_role/ # child table, one Role per row +``` + +--- + +## Data model + +**Time Based Overdue Limit** (not submittable, named `TBOL-.#####`, title `party`): + +| Field | Type | Notes | +|-------|------|-------| +| `enabled` | Check | Default 1. Only enabled rules are loaded or count as duplicates | +| `applies_to` | Select | Customer / Customer Group. Default Customer | +| `party` | Dynamic Link → `applies_to` | Required | +| `company` | Link Company | Optional. Blank means every company (see [Blank company](#blank-company)) | +| `action` | Select | Warn on Save / Block on Save / Block on Submit | +| `threshold_days` | Int | Required, must be above 0 | +| `exempt_roles` | Table MultiSelect → Time Based Overdue Limit Exempt Role | Shown only for the two block actions. Ignored on Warn on Save even when rows remain from an earlier action | + +**Time Based Overdue Limit Exempt Role** (child): `role`, Link Role, required. + +Controller `validate`: + +- `threshold_days` must be above 0. +- No other **enabled** rule may share `applies_to`, `party`, `company` and `action`. A blank company + only clashes with another blank company, so an every-company rule and a one-company rule with the + same action can coexist, and both apply. + +Permissions: **Accounts Manager** and **System Manager** have full access; **Accounts User** and +**Sales User** read only. If sales users could edit rules or their exempt roles, the control would +be worthless. + +--- + +## When the check runs + +Each hook checks the rules of one action only, so a rule does what its name says and nothing more: + +| Hook | Action checked | Runs on | +|------|----------------|---------| +| `validate` | **Warn on Save** | save and submit | +| `before_save` | **Block on Save** | save | +| `before_submit` | **Block on Submit** | submit | + +Frappe runs `validate → before_save` on a save and `validate → before_submit` on a submit +(`frappe/model/document.py`, `run_before_save_methods`). Three consequences: + +- A Warn on Save rule warns again at submit, because `validate` runs there too. +- Submitting does **not** run `before_save`, so a draft saved before the customer went overdue (or + before the rule existed) submits past a Block on Save rule. A Block on Submit rule is what stops a + submit. +- Each hook sends its own message. When a Warn on Save and a Block on Save rule both trigger, + `validate` queues the warning and `before_save` then refuses. Both reach the browser in + `_server_messages`, but the desk shows only the refusal: given a list in which any message + raised, `frappe.msgprint` drops the others (`frappe/public/js/frappe/ui/messages.js`). A client + reading the API response gets both. When the user is exempt from the block, nothing raises, and + the desk stacks the two warnings in one dialog, each with its own header line. + +```mermaid +flowchart LR + A["validate / before_save / before_submit
(one action each)"] --> B{credit note, POS consolidated,
migrate, patch, install, import?} + B -- yes --> Z[nothing] + B -- no --> C{rules of that action in
the most specific tier?} + C -- none --> Z + C -- some --> D{oldest overdue
Sales Invoice?} + D -- none --> Z + D -- found --> E{decide: warn,
block or nothing} + E -- nothing --> Z + E -- warn / block --> G[msgprint warning
or throw OverdueLimitError] +``` + +Skipped documents, `is_exempt_document`: + +| Skipped | Why | +|---------|-----| +| `is_return` | A credit note only lowers what the customer owes | +| `is_consolidated` | POS Closing creates these; blocking one would stop the closing | +| `frappe.flags.in_migrate` / `in_patch` / `in_install` / `in_import` | System writes and Data Import are not sales decisions | + +Sales Order has neither `is_return` nor `is_consolidated`; `doc.get` returns `None` for them. + +--- + +## Rule selection + +`get_group_chain` reads the customer's group (`Customer.customer_group`, not the document's +fetched copy) and its ancestors, nearest first, with one `lft <= … and rgt >= …` query. The +`lft` / `rgt` of the group are read uncached: adding a group renumbers the tree in SQL, which +leaves cached documents stale. `get_enabled_rules` loads every enabled rule naming the customer or +any group in the chain, with their exempt roles. + +`select_rules` then keeps the rules whose company is blank or equals the document's, finds the +first tier that has any, in this order, and returns that tier's rules of the hook's action: + +``` +(Customer, Al Noor Trading) → (Customer Group, Retail) → (Customer Group, Commercial) → (Customer Group, All Customer Groups) +``` + +The tier is chosen **before** the action filter: a customer with only a Warn on Save rule is never +blocked by its group's Block on Save rule. The company filter runs before the tier search, so a +customer rule for another company does not hide the group's rules. `applies_to` is matched with +`party`, so a group named like the customer is still a group. + +--- + +## Day count + +`get_oldest_overdue_invoice` returns the Sales Invoice with the earliest `due_date` among: + +``` +customer = doc.customer, company = doc.company, status in ('Overdue', 'Overdue and Discounted') +``` + +ordered `due_date, name` and limited to one, so the message can name it. `days_overdue` is +`today − due_date`, and a rule triggers when `overdue_days >= threshold_days`. + +What counts as overdue is ERPNext's decision, read from the invoice's `status`. ERPNext gives +`Overdue` only to a submitted invoice with an outstanding amount, so drafts, cancelled and paid +invoices and credit notes never count. An Unpaid invoice becomes Overdue in two ways: + +- `update_invoice_status` (`erpnext/controllers/accounts_controller.py`), in ERPNext's + `daily_maintenance` scheduler events. It is the only thing that flips an invoice nobody touches. +- `set_status`, whenever something else updates the invoice, such as a payment against it. + +So the status trails the calendar by up to a day: only a 1-day threshold can fire late. On a site +whose **scheduler is disabled**, untouched invoices stay Unpaid and the rules stop firing. + +`Overdue and Discounted` is the same status for an invoice under Invoice Discounting: the bank has +advanced the money, but the customer still owes it. + +`Sales Invoice.due_date` is the **latest** due date of the payment schedule (`self.due_date = +max(due_dates)`). ERPNext marks an installment invoice Overdue as soon as any installment is late, +but the day count runs from that last due date, which may still be ahead: the days then come out +negative and no rule triggers until the last installment is `threshold_days` late. The check +under-counts, but never blocks wrongly. Per-installment ageing from `Payment Schedule` is a possible +follow-up. + +--- + +## Outcome + +`decide(rules, overdue_days, user_roles)` takes the rules of one action. A rule triggers when +`overdue_days >= threshold_days`, and then: + +| Action | User not exempt | User exempt | +|--------|-----------------|-------------| +| **Warn on Save** | warn | warn | +| **Block on Save** | **block** | warn | +| **Block on Submit** | **block** | warn | + +"Exempt" means the acting user (`frappe.get_roles()`, the session user, not the document owner) +holds one of the rule's exempt roles, which only counts on the two block actions (`is_exempt_user`). + +One hook can meet two rules of its action: an every-company rule and a one-company rule. `decide` +combines the triggered ones into a `Verdict(outcome, hits)`: + +- Any block wins. Its hits are only the blocking rules, so the refusal lists only what refuses. +- Otherwise every triggered rule is a hit, and the warning lists them all. +- No triggered rule: `Verdict(None, [])`. + +Each `Hit` carries the rule and whether the user was exempt from it. The message turns each into one +line naming the rule, its action and its threshold (*Rule TBOL-00002 (Block on Save) applies from 30 +days overdue.*), adding *Your role lets you continue* or the list of exempt roles. A block is raised as +`OverdueLimitError`, a subclass of `frappe.ValidationError`. A warning is `msgprint` with an orange +indicator, and the save or submit goes on. + +`frappe.get_roles("Administrator")` returns every role, so Administrator is exempt from any block +rule that lists at least one exempt role. A block rule with no exempt roles blocks everyone. + +--- + +## Blank company + +Frappe fills an empty Link field of a new document from `frappe.defaults` keyed by the +**fieldname** (`frappe/model/create_new.py`, `get_user_default_value`), and every ERPNext site has +a global `company` default. Left alone, insert would turn every blank-company rule into a +default-company rule. The controller's `__setup__` sets `dont_update_if_missing = ["company"]`, so +the server keeps a blank company blank. + +The form still pre-fills the user's default company on a new rule (the client-side +`frappe.model.get_default_value`); clearing it is what makes a rule apply in every company. + +--- + +## Known limits + +| Limit | Effect | Possible follow-up | +|-------|--------|--------------------| +| Block on Save is not checked at submit | A draft saved before the customer went overdue can be submitted | Add a Block on Submit rule for the same party | +| Installment invoices age from their last due date | Overdue installments count late | Age from `Payment Schedule` rows | +| Overdue comes from ERPNext's daily job | A site with the scheduler disabled never blocks; elsewhere a 1-day threshold can fire up to a day late | Keep the scheduler enabled | +| Residual balances count | 0.01 outstanding can block | An "ignore outstanding below" amount | +| Unallocated payments and credit notes | The invoice stays outstanding and still counts, even when the customer owes nothing overall | None; reconcile them with Payment Reconciliation | +| A submitted Sales Order does not pass its Sales Invoice | The invoice is checked on its own | Intended | +| Cancel + amend, Auto Repeat and other server inserts | Checked like any save | Intended | + +--- + +## Tests + +See [testing.md](../development/testing.md). The check's tests come as a pair in +`optima_payment/overdue_limit/tests/`, and the rule's own validation sits beside the doctype: + +| File | Layer | What it proves | +|------|-------|----------------| +| `test_limit.py` | Matrix: plain rules, nothing written | The documents never checked, tier precedence, the tier chosen before the action, other-company rules, a group named like the customer, the threshold day, how several triggered rules combine (including an exemption from only one), what the message says about exemption. The message wording is not pinned | +| `test_limit_integration.py` | Integration: real saves | Each action at its own hook on Sales Invoice and Sales Order: Warn on Save warning at save and again at submit, Block on Save refusing the save but not an earlier draft's submit, Block on Submit saving silently and refusing the submit; the acting user's roles deciding the exemption, a credit note saving and submitting under both blocks, an ancestor group's rule applying, another company's rule not applying, an allocated payment lifting the block and an unallocated one not, a disabled rule | +| `doctype/time_based_overdue_limit/test_time_based_overdue_limit.py` | Integration | Threshold above 0, the duplicate rule, what is not a duplicate, a blank company staying blank, re-saving a rule | + +Every integration test starts from a customer whose Sales Invoice fell due 25 days ago and that +ERPNext's own `update_invoice_status` has marked Overdue (`make_invoice_overdue`). The fixtures come from `optima_payment/tests/utils.py`. diff --git a/docs/overdue-limit/user-guide.md b/docs/overdue-limit/user-guide.md new file mode 100644 index 0000000..bae7aaa --- /dev/null +++ b/docs/overdue-limit/user-guide.md @@ -0,0 +1,168 @@ +# Time Based Overdue Limit — User Guide + +A plain-language guide for accountants and implementers to warn about, or stop, new sales to +customers who have left an invoice unpaid too long. For the technical model, see +[reference.md](reference.md). + +--- + +## What it does + +You set a rule for a customer, or for a whole customer group: *"once this customer has an invoice +**20 days** past its due date, **warn** the person making a new sale"*, or **block** the sale +instead. The rule is checked on every new **Sales Invoice** and **Sales Order** for that customer. + +### Worked example + +Today is 02-11-2026. Customer **Al Noor Trading** has one unpaid invoice: + +| Invoice | Due date | Outstanding | +|---------|----------|-------------| +| SINV-2026-00041 | 08-10-2026 | 1,150.00 | + +It is **25 days** past its due date. Two rules are set for the customer: + +| Rule | Action | Threshold Days | +|------|--------|----------------| +| TBOL-00001 | Warn on Save | 20 | +| TBOL-00002 | Block on Save | 30 | + +A sales user saves a new Sales Order for Al Noor Trading. 25 days is past TBOL-00001's 20, but not +yet TBOL-00002's 30, so the order **saves** and shows: + +> Customer **Al Noor Trading** has Sales Invoice **SINV-2026-00041** overdue by **25** days; it was +> due on 08-10-2026. +> - Rule **TBOL-00001** (Warn on Save) applies from 20 days overdue. + +Five days later the invoice is 30 days overdue, and TBOL-00002 **refuses** the save until the +invoice is paid. Only the refusal shows: when a save is refused, the TBOL-00001 warning is not +displayed with it. A user with an exempt role on TBOL-00002 would instead see two messages, one +under the other: the TBOL-00001 warning, then TBOL-00002 with *Your role lets you continue*. + +--- + +## Who can set rules + +Only **Accounts Manager** and **System Manager** can create, change or delete rules. Sales Users +and Accounts Users can open them but not change them, so nobody can lift a block on their own +customer or add their own role to the exempt list. + +--- + +## Create a rule (step by step) + +1. New **Time Based Overdue Limit**. +2. **Applies To**: *Customer* for one customer, or *Customer Group* for every customer in that + group and in all the groups under it. +3. **Party**: pick the customer or the group. +4. **Company**: the rule only counts invoices of this company and only checks its documents. + A new rule starts with your default company. **Clear it** to apply the rule in every company. +5. **Action**: see the table below. +6. **Threshold Days**: how many days past its due date the oldest unpaid invoice must be. The + rule applies **from** that day: with 20, an invoice 20 days overdue already counts. +7. *(Block actions only)* **Exempt Roles**: users with any of these roles get the warning but are + not blocked. +8. **Save**. Untick **Enabled** to switch a rule off without deleting it. + +A customer can have several rules, for example a 20-day warning and a 30-day block. Two +enabled rules with the same customer or group, company and action are not allowed. + +--- + +## The three actions + +Each action does exactly what its name says, and nothing at the other step: + +| Action | Saving a draft | Submitting | +|--------|----------------|------------| +| **Warn on Save** | Saves, with a warning | Goes through, with the same warning | +| **Block on Save** | **Refused** | Not checked | +| **Block on Submit** | Not checked | **Refused** | + +- **Warn on Save** shows again when you submit, because submitting saves the document once more. +- **Block on Save** does not stop a submit: a draft saved before the customer became overdue can + still be submitted. To stop submits too, add a **Block on Submit** rule for the same customer. +- **Block on Submit** says nothing at save; the draft saves normally and only the submit is refused. +- A user with an **exempt role** sees the warning instead of the refusal. Exempt roles have no + effect on **Warn on Save**: everybody sees that warning. +- Exemption is **per rule**. Being exempt from one block rule does not help against another block + rule that lists different roles, or none. +- The **Administrator** user holds every role, so is exempt from any block rule that lists at least + one exempt role. A block rule with no exempt roles blocks everyone, Administrator included. + +--- + +## Which rule applies + +The most specific rules win, and only those: + +1. The customer's **own** rules. +2. If it has none, the rules of **its customer group**. +3. If that group has none, the rules of the **parent group**, then its parent, and so on up the + tree. + +All the rules at that first level apply together. A level counts as soon as it has any rule, +whatever its action: a customer with only its own Warn on Save rule is never blocked by its group's +Block on Save rule. A rule for a different company is ignored and does not hide the rules above it. + +``` +Al Noor Trading (no rule) → Retail (no rule) → Commercial: Block on Save, 30 days ← applies + ↓ + All Customer Groups: Warn on Save, 10 days ← ignored +``` + +--- + +## How the days are counted + +- Only the customer's **Sales Invoices** in the document's **company** that ERPNext shows as + **Overdue** (or **Overdue and Discounted**, under Invoice Discounting) are counted. Paid + invoices, drafts and credit notes never are. +- The **oldest** due date among them decides. Newer overdue invoices don't change the count. +- The day count is today minus that due date. +- ERPNext marks invoices Overdue once a day, so an invoice that fell due yesterday can still show + **Unpaid** for a few hours. If the site's **scheduler** is switched off, invoices stay Unpaid and + no rule fires. +- On an invoice paid in installments, ERPNext shows **Overdue** as soon as one installment is late, + but the days are counted from the **last** installment's due date, so the rule waits for that one. + +--- + +## What is never checked + +- **Credit notes** (return invoices), so you can always reduce what the customer owes. +- **POS consolidated invoices**, which POS Closing creates. Blocking them would stop the closing. +- Documents created by **Data Import**, a migration or an app install. + +--- + +## Things to know + +- **A submitted Sales Order does not let its Sales Invoice through.** The invoice is checked on its + own when it is made. +- **Cancel and amend.** The amended document is a new save, so it is blocked like any other + unless you hold an exempt role. +- **With Block on Save, a sales user can't even prepare a draft** for an overdue customer. Choose + **Block on Submit** if drafts should still be allowed. +- **Only allocated payments count.** A payment or credit note recorded for the customer but not + allocated to the overdue invoice leaves that invoice unpaid, so the rule still applies, even + when the customer owes nothing overall. Match it to the invoice with **Payment Reconciliation**. +- **A tiny leftover balance counts.** An invoice with 0.01 outstanding is still unpaid and can + block. Write such balances off. +- **Background jobs are checked too.** A Sales Invoice made automatically (for example by Auto + Repeat) for a blocked customer is refused the same way. + +--- + +## Troubleshooting + +| Message or symptom | Cause | Fix | +|--------------------|-------|-----| +| *Customer X has Sales Invoice Y overdue by N days … (Block on Save) applies …* when you save | A Block on Save rule applies | Collect and record the payment against invoice Y, or ask a user with an exempt role | +| *… (Block on Submit) applies …* when you submit | A Block on Submit rule applies | As above. The draft stays saved | +| A draft was submitted for an overdue customer | The customer only has a Block on Save rule, and the draft was saved before the rule applied | Add a Block on Submit rule | +| A customer is blocked though they have paid | The payment or credit note is recorded but not allocated to the overdue invoice | Allocate it with **Payment Reconciliation** | +| *Threshold Days must be greater than 0* | Threshold Days is 0 or empty | Enter a number of days above 0 | +| *Enabled rule … already has the same Applies To, Party, Company and Action* | A second rule repeats an enabled one | Change the existing rule instead, or disable it first | +| A rule never fires though the invoice is long past due | The invoice still shows **Unpaid**: the site's scheduler is switched off | Ask the administrator to enable the scheduler | +| A rule never fires in one company | It names a different company | Clear **Company**, or set the right one | diff --git a/optima_payment/doc_events/overdue_limit.py b/optima_payment/doc_events/overdue_limit.py new file mode 100644 index 0000000..0e7f675 --- /dev/null +++ b/optima_payment/doc_events/overdue_limit.py @@ -0,0 +1,15 @@ +"""Sales Invoice and Sales Order hooks for the Time Based Overdue Limit, one action per hook.""" + +from optima_payment.overdue_limit import limit + + +def validate(doc, method=None): + limit.check(doc, limit.WARN_ON_SAVE) + + +def before_save(doc, method=None): + limit.check(doc, limit.BLOCK_ON_SAVE) + + +def before_submit(doc, method=None): + limit.check(doc, limit.BLOCK_ON_SUBMIT) diff --git a/optima_payment/hooks.py b/optima_payment/hooks.py index c6d6532..4ae0288 100644 --- a/optima_payment/hooks.py +++ b/optima_payment/hooks.py @@ -189,6 +189,16 @@ "Purchase Receipt": { "after_delete": "optima_payment.purchases_bulk.vouchers.unlink_tool_rows", }, + "Sales Invoice": { + "validate": "optima_payment.doc_events.overdue_limit.validate", + "before_save": "optima_payment.doc_events.overdue_limit.before_save", + "before_submit": "optima_payment.doc_events.overdue_limit.before_submit", + }, + "Sales Order": { + "validate": "optima_payment.doc_events.overdue_limit.validate", + "before_save": "optima_payment.doc_events.overdue_limit.before_save", + "before_submit": "optima_payment.doc_events.overdue_limit.before_submit", + }, # "Expense Claim" : { # "on_submit" : "optima_payment.doc_events.expense_claim.on_submit", # "on_cancel" : "optima_payment.doc_events.expense_claim.on_cancel", diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit/__init__.py b/optima_payment/optima_payment/doctype/time_based_overdue_limit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit/test_time_based_overdue_limit.py b/optima_payment/optima_payment/doctype/time_based_overdue_limit/test_time_based_overdue_limit.py new file mode 100644 index 0000000..512f8f5 --- /dev/null +++ b/optima_payment/optima_payment/doctype/time_based_overdue_limit/test_time_based_overdue_limit.py @@ -0,0 +1,47 @@ +# Copyright (c) 2026, IT Systematic and Contributors +# See license.txt + +"""The rule's own validation: a positive threshold, and one enabled rule per party, company and +action.""" + +import erpnext +import frappe +from frappe.tests.utils import FrappeTestCase + +from optima_payment.overdue_limit.limit import BLOCK_ON_SAVE, WARN_ON_SAVE +from optima_payment.tests.utils import get_or_create_customer, make_overdue_limit + +CUSTOMER = "Optima Overdue Test Customer" + + +class TestTimeBasedOverdueLimit(FrappeTestCase): + def setUp(self): + self.customer = get_or_create_customer(CUSTOMER) + + def tearDown(self): + frappe.db.rollback() + + def test_the_threshold_must_be_above_zero(self): + for days in (0, -5): + with self.subTest(days=days), self.assertRaises(frappe.ValidationError): + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE, threshold_days=days) + + def test_a_second_enabled_rule_for_the_same_party_company_and_action_is_refused(self): + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE, threshold_days=20) + with self.assertRaises(frappe.ValidationError): + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE, threshold_days=30) + + def test_rules_differing_in_action_company_or_enabled_are_not_duplicates(self): + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE) + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE, company=erpnext.get_default_company()) + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE, enabled=0) + + def test_a_blank_company_is_not_filled_with_the_default_company(self): + rule = make_overdue_limit(party=self.customer, action=WARN_ON_SAVE) + self.assertFalse(frappe.db.get_value(rule.doctype, rule.name, "company")) + + def test_a_rule_can_be_saved_again(self): + rule = make_overdue_limit(party=self.customer, action=WARN_ON_SAVE) + rule.threshold_days = 25 + rule.save(ignore_permissions=True) diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.json b/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.json new file mode 100644 index 0000000..de6a0b3 --- /dev/null +++ b/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.json @@ -0,0 +1,137 @@ +{ + "actions": [], + "autoname": "TBOL-.#####", + "creation": "2026-11-02 10:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_rule", + "enabled", + "applies_to", + "party", + "company", + "column_break_action", + "action", + "threshold_days", + "exempt_roles" + ], + "fields": [ + { + "fieldname": "section_break_rule", + "fieldtype": "Section Break" + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "default": "Customer", + "fieldname": "applies_to", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Applies To", + "options": "Customer\nCustomer Group", + "reqd": 1 + }, + { + "fieldname": "party", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Party", + "options": "applies_to", + "reqd": 1 + }, + { + "description": "Leave blank to apply the rule in every company.", + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company" + }, + { + "fieldname": "column_break_action", + "fieldtype": "Column Break" + }, + { + "fieldname": "action", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Action", + "options": "Warn on Save\nBlock on Save\nBlock on Submit", + "reqd": 1 + }, + { + "description": "The rule applies once the customer's oldest overdue Sales Invoice is at least this many days past its due date.", + "fieldname": "threshold_days", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Threshold Days", + "non_negative": 1, + "reqd": 1 + }, + { + "depends_on": "eval:doc.action != \"Warn on Save\"", + "description": "Users with any of these roles get a warning instead of being blocked.", + "fieldname": "exempt_roles", + "fieldtype": "Table MultiSelect", + "label": "Exempt Roles", + "options": "Time Based Overdue Limit Exempt Role" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-11-02 10:00:00", + "modified_by": "Administrator", + "module": "Optima Payment", + "name": "Time Based Overdue Limit", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Accounts User" + }, + { + "read": 1, + "role": "Sales User" + } + ], + "row_format": "Dynamic", + "search_fields": "party", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "party" +} diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.py b/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.py new file mode 100644 index 0000000..70b1060 --- /dev/null +++ b/optima_payment/optima_payment/doctype/time_based_overdue_limit/time_based_overdue_limit.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026, IT Systematic and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint + + +class TimeBasedOverdueLimit(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + from optima_payment.optima_payment.doctype.time_based_overdue_limit_exempt_role.time_based_overdue_limit_exempt_role import TimeBasedOverdueLimitExemptRole + + action: DF.Literal["Warn on Save", "Block on Save", "Block on Submit"] + applies_to: DF.Literal["Customer", "Customer Group"] + company: DF.Link | None + enabled: DF.Check + exempt_roles: DF.TableMultiSelect[TimeBasedOverdueLimitExemptRole] + party: DF.DynamicLink + threshold_days: DF.Int + # end: auto-generated types + + def __setup__(self): + # A blank company means every company, so insert must not fill in the default company. + self.dont_update_if_missing = ["company"] + + def validate(self): + self.validate_threshold_days() + self.validate_unique_rule() + + def validate_threshold_days(self): + if cint(self.threshold_days) <= 0: + frappe.throw(_("Threshold Days must be greater than 0.")) + + def validate_unique_rule(self): + if not self.enabled: + return + + duplicate = frappe.db.exists( + self.doctype, + { + "name": ("!=", self.name), + "enabled": 1, + "applies_to": self.applies_to, + "party": self.party, + "company": self.company or ("is", "not set"), + "action": self.action, + }, + ) + if duplicate: + frappe.throw( + _("Enabled rule {0} already has the same Applies To, Party, Company and Action.").format( + frappe.bold(duplicate) + ) + ) diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/__init__.py b/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.json b/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.json new file mode 100644 index 0000000..838c9d1 --- /dev/null +++ b/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-11-02 10:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role" + ], + "fields": [ + { + "fieldname": "role", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Role", + "options": "Role", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-11-02 10:00:00", + "modified_by": "Administrator", + "module": "Optima Payment", + "name": "Time Based Overdue Limit Exempt Role", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.py b/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.py new file mode 100644 index 0000000..fb762fc --- /dev/null +++ b/optima_payment/optima_payment/doctype/time_based_overdue_limit_exempt_role/time_based_overdue_limit_exempt_role.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026, IT Systematic and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class TimeBasedOverdueLimitExemptRole(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + role: DF.Link + # end: auto-generated types + pass diff --git a/optima_payment/overdue_limit/__init__.py b/optima_payment/overdue_limit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/optima_payment/overdue_limit/limit.py b/optima_payment/overdue_limit/limit.py new file mode 100644 index 0000000..5faad46 --- /dev/null +++ b/optima_payment/overdue_limit/limit.py @@ -0,0 +1,218 @@ +# Copyright (c) 2026, IT Systematic and contributors +# For license information, please see license.txt + +"""Time Based Overdue Limit: warn about or block a Sales Invoice or Sales Order whose customer has +a Sales Invoice unpaid too long past its due date. + +The age that counts is that of the customer's oldest overdue invoice in the document's company. Only +the most specific tier of rules applies: the customer's own, else its group's, else the nearest +ancestor group's. Each action is checked at its own hook: Warn on Save at validate, Block on Save at +before_save, Block on Submit at before_submit. + +Branching lives in the DECISIONS functions, which take plain rows and return values. +""" + +from collections import namedtuple + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import formatdate, getdate + +RULE = "Time Based Overdue Limit" +EXEMPT_ROLE = "Time Based Overdue Limit Exempt Role" +CUSTOMER = "Customer" +CUSTOMER_GROUP = "Customer Group" + +WARN_ON_SAVE = "Warn on Save" +BLOCK_ON_SAVE = "Block on Save" +BLOCK_ON_SUBMIT = "Block on Submit" + +WARN = "warn" +BLOCK = "block" + +SKIP_FLAGS = ("in_migrate", "in_patch", "in_install", "in_import") +OVERDUE_STATUSES = ("Overdue", "Overdue and Discounted") + +Hit = namedtuple("Hit", ["rule", "exempt"]) +Verdict = namedtuple("Verdict", ["outcome", "hits"]) + + +class OverdueLimitError(frappe.ValidationError): + pass + + +# ==================================================================================================== +# DECISIONS +# Pure: no database. + + +def is_exempt_document(is_return, is_consolidated, flags) -> bool: + """Credit notes only lower what the customer owes, and blocking a POS consolidated invoice would + break POS Closing. Nothing written by migrate, a patch, an install or a data import is checked.""" + return bool(is_return or is_consolidated or any(flags.get(flag) for flag in SKIP_FLAGS)) + + +def select_rules(rules: list, customer: str, group_chain: list[str], company: str, action: str) -> list: + """The ``action`` rules governing ``customer`` in ``company``. + + The tier is the first that has any rule, whatever its action, so a customer's Warn on Save rule + still shadows its group's Block on Save rule. ``group_chain`` is the customer's group followed by + its ancestors, nearest first. A rule with no company applies in every company, and a rule for + another company does not shadow a wider tier. + """ + in_company = [rule for rule in rules if not rule.company or rule.company == company] + tiers = [(CUSTOMER, customer)] + [(CUSTOMER_GROUP, group) for group in group_chain] + for applies_to, party in tiers: + tier = [rule for rule in in_company if rule.applies_to == applies_to and rule.party == party] + if tier: + return [rule for rule in tier if rule.action == action] + return [] + + +def days_overdue(due_date, today) -> int: + return (getdate(today) - getdate(due_date)).days + + +def is_exempt_user(rule, user_roles: set) -> bool: + """Exempt roles only turn a block into a warning, so they mean nothing on a Warn on Save rule.""" + return rule.action != WARN_ON_SAVE and bool(user_roles & set(rule.exempt_roles)) + + +def decide(rules: list, overdue_days: int, user_roles) -> Verdict: + """What ``rules``, all of one action, do once the customer is ``overdue_days`` overdue. + + A rule triggers once ``overdue_days`` reaches its ``threshold_days``. Warn on Save warns. A block + rule blocks, but only warns a user holding one of its exempt roles. A block outranks warnings, which + its message then leaves out. + """ + user_roles = set(user_roles) + hits = {BLOCK: [], WARN: []} + for rule in rules: + if overdue_days < rule.threshold_days: + continue + exempt = is_exempt_user(rule, user_roles) + outcome = WARN if rule.action == WARN_ON_SAVE or exempt else BLOCK + hits[outcome].append(Hit(rule, exempt)) + + for outcome in (BLOCK, WARN): + if hits[outcome]: + return Verdict(outcome, hits[outcome]) + return Verdict(None, []) + + +# ==================================================================================================== +# MESSAGES + + +def message(verdict: Verdict, customer: str, invoice, days: int) -> str: + header = _("Customer {0} has Sales Invoice {1} overdue by {2} days; it was due on {3}.").format( + frappe.bold(customer), frappe.bold(invoice.name), frappe.bold(days), formatdate(invoice.due_date) + ) + lines = "".join(f"
  • {_hit_line(hit)}
  • " for hit in verdict.hits) + return f"{header}
      {lines}
    " + + +def _hit_line(hit: Hit) -> str: + rule = hit.rule + line = _("Rule {0} ({1}) applies from {2} days overdue.").format( + frappe.bold(rule.name), _(rule.action), rule.threshold_days + ) + if hit.exempt: + return f"{line} {_('Your role lets you continue.')}" + if rule.action != WARN_ON_SAVE and rule.exempt_roles: + roles = ", ".join(_(role) for role in rule.exempt_roles) + return f"{line} {_('Roles that can continue: {0}.').format(roles)}" + return line + + +# ==================================================================================================== +# CHECK + + +def check(doc: Document, action: str) -> None: + """Warns about or blocks a Sales Invoice or Sales Order as its customer's ``action`` rules say.""" + if not doc.customer or is_exempt_document(doc.get("is_return"), doc.get("is_consolidated"), frappe.flags): + return + + rules = get_rules(doc.customer, doc.company, action) + if not rules: + return + + invoice = get_oldest_overdue_invoice(doc.customer, doc.company) + if not invoice: + return + + days = days_overdue(invoice.due_date, getdate()) + verdict = decide(rules, days, frappe.get_roles()) + if not verdict.outcome: + return + + text = message(verdict, doc.customer, invoice, days) + if verdict.outcome == BLOCK: + frappe.throw(text, OverdueLimitError, title=_("Overdue Limit")) + frappe.msgprint(text, title=_("Overdue Limit"), indicator="orange") + + +# ==================================================================================================== +# QUERIES + + +def get_rules(customer: str, company: str, action: str) -> list: + group_chain = get_group_chain(frappe.db.get_value(CUSTOMER, customer, "customer_group")) + return select_rules(get_enabled_rules([customer, *group_chain]), customer, group_chain, company, action) + + +def get_group_chain(customer_group: str | None) -> list[str]: + """``customer_group`` and its ancestors, nearest first. + + Read lft and rgt uncached: adding a group renumbers the rest of the tree in SQL, which leaves + their cached documents stale. + """ + if not customer_group: + return [] + + lft, rgt = frappe.db.get_value(CUSTOMER_GROUP, customer_group, ["lft", "rgt"]) + return frappe.get_all( + CUSTOMER_GROUP, + filters={"lft": ("<=", lft), "rgt": (">=", rgt)}, + order_by="lft desc", + pluck="name", + ) + + +def get_enabled_rules(parties: list[str]) -> list: + """The enabled rules naming any of ``parties``, each with its ``exempt_roles`` as a list.""" + rules = frappe.get_all( + RULE, + filters={"enabled": 1, "party": ("in", parties)}, + fields=["name", "applies_to", "party", "company", "action", "threshold_days"], + order_by="threshold_days asc, name asc", + ) + if not rules: + return [] + + roles = frappe.get_all( + EXEMPT_ROLE, + filters={"parenttype": RULE, "parent": ("in", [rule.name for rule in rules])}, + fields=["parent", "role"], + ) + for rule in rules: + rule.exempt_roles = [row.role for row in roles if row.parent == rule.name] + return rules + + +def get_oldest_overdue_invoice(customer: str, company: str): + """The Sales Invoice ERPNext marks Overdue whose due date is furthest past, or None. + + ERPNext's daily ``update_invoice_status`` job is what turns an Unpaid invoice Overdue, so on a + site whose scheduler is off, no invoice counts. + """ + invoices = frappe.get_all( + "Sales Invoice", + filters={"customer": customer, "company": company, "status": ("in", OVERDUE_STATUSES)}, + fields=["name", "due_date"], + order_by="due_date asc, name asc", + limit=1, + ) + return invoices[0] if invoices else None diff --git a/optima_payment/overdue_limit/tests/__init__.py b/optima_payment/overdue_limit/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/optima_payment/overdue_limit/tests/test_limit.py b/optima_payment/overdue_limit/tests/test_limit.py new file mode 100644 index 0000000..12d1dcd --- /dev/null +++ b/optima_payment/overdue_limit/tests/test_limit.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026, IT Systematic and Contributors +# See license.txt + +"""The Time Based Overdue Limit's decisions, on plain rows. Nothing here writes to the database. + +Only what a plausible edit could break is here: which tier of rules applies and which of its rules +each action picks, how several triggered rules combine, the day a rule starts, what the message tells +the user about exemption, and the documents never checked, which no integration test reaches for POS. +Which hook checks which action is wiring, in test_limit_integration.py. +""" + +import frappe +from frappe.tests.utils import FrappeTestCase + +from optima_payment.overdue_limit.limit import ( + BLOCK, + BLOCK_ON_SAVE, + CUSTOMER, + CUSTOMER_GROUP, + WARN, + WARN_ON_SAVE, + days_overdue, + decide, + is_exempt_document, + message, + select_rules, +) + +CUSTOMER_NAME = "Al Noor Trading" +GROUP = "Retail" +PARENT_GROUP = "Commercial" +ROOT_GROUP = "All Customer Groups" +GROUP_CHAIN = [GROUP, PARENT_GROUP, ROOT_GROUP] +COMPANY = "Optima Test Company" +OTHER_COMPANY = "Optima Other Company" +EXEMPT_ROLE = "Accounts Manager" +SALES_ROLES = ["Sales User"] +INVOICE = frappe._dict(name="SINV-2026-00001", due_date="2026-10-08") + + +def rule( + name="TBOL-00001", + *, + applies_to=CUSTOMER, + party=CUSTOMER_NAME, + company=None, + action=WARN_ON_SAVE, + threshold_days=20, + exempt_roles=(), +): + """A rule the way get_enabled_rules returns it.""" + return frappe._dict( + name=name, + applies_to=applies_to, + party=party, + company=company, + action=action, + threshold_days=threshold_days, + exempt_roles=list(exempt_roles), + ) + + +def group_rule(name, group, **fields): + return rule(name, applies_to=CUSTOMER_GROUP, party=group, **fields) + + +def hit_names(verdict): + return [hit.rule.name for hit in verdict.hits] + + +class TestIsExemptDocument(FrappeTestCase): + def test_credit_notes_pos_consolidations_and_system_writes_are_never_checked(self): + """Flags by their literal names: looping over SKIP_FLAGS cannot catch a misspelt one.""" + self.assertFalse(is_exempt_document(None, None, frappe._dict())) + self.assertTrue(is_exempt_document(1, 0, frappe._dict())) + self.assertTrue(is_exempt_document(0, 1, frappe._dict())) + for flag in ("in_migrate", "in_patch", "in_install", "in_import"): + with self.subTest(flag): + self.assertTrue(is_exempt_document(0, 0, frappe._dict({flag: True}))) + + +class TestSelectRules(FrappeTestCase): + def select(self, rules, company=COMPANY, action=WARN_ON_SAVE): + return [rule.name for rule in select_rules(rules, CUSTOMER_NAME, GROUP_CHAIN, company, action)] + + def test_the_nearest_tier_with_a_rule_wins(self): + customer, group, parent, root = ( + rule("CUSTOMER"), + group_rule("GROUP", GROUP), + group_rule("PARENT", PARENT_GROUP), + group_rule("ROOT", ROOT_GROUP), + ) + # Furthest first, so the order of the rows cannot be what picks the winner. + cases = [ + ([root, parent, group, customer], "CUSTOMER"), + ([root, parent, group], "GROUP"), + ([root, parent], "PARENT"), + ] + for rules, winner in cases: + with self.subTest(winner): + self.assertEqual(self.select(rules), [winner]) + + def test_every_rule_of_the_winning_tier_with_the_action_applies(self): + rules = [ + rule("EVERYWHERE", action=BLOCK_ON_SAVE), + rule("HERE", company=COMPANY, action=BLOCK_ON_SAVE), + rule("WARN"), + group_rule("GROUP", GROUP, action=BLOCK_ON_SAVE), + ] + self.assertEqual(self.select(rules, action=BLOCK_ON_SAVE), ["EVERYWHERE", "HERE"]) + + def test_the_tier_is_chosen_before_the_action(self): + """Filtering by action first would let the group's block through a customer that has only a + warning.""" + rules = [rule("WARN"), group_rule("GROUP", GROUP, action=BLOCK_ON_SAVE)] + self.assertEqual(self.select(rules, action=BLOCK_ON_SAVE), []) + + def test_a_company_rule_neither_applies_nor_shadows_in_another_company(self): + rules = [rule("ELSEWHERE", company=OTHER_COMPANY), group_rule("EVERYWHERE", GROUP)] + self.assertEqual(self.select(rules, company=COMPANY), ["EVERYWHERE"]) + self.assertEqual(self.select(rules, company=OTHER_COMPANY), ["ELSEWHERE"]) + + def test_a_group_named_like_the_customer_is_not_a_customer_rule(self): + self.assertEqual(self.select([group_rule("GROUP", CUSTOMER_NAME)]), []) + + +class TestDecide(FrappeTestCase): + def outcome_on(self, today, rules): + return decide(rules, days_overdue(INVOICE.due_date, today), SALES_ROLES).outcome + + def test_a_rule_starts_on_its_threshold_day(self): + rules = [rule(action=BLOCK_ON_SAVE, threshold_days=20)] + self.assertIsNone(self.outcome_on("2026-10-27", rules)) + self.assertEqual(self.outcome_on("2026-10-28", rules), BLOCK) + + def test_the_refusal_lists_only_the_rules_that_block_this_user(self): + rules = [ + rule("EXEMPTED", action=BLOCK_ON_SAVE, exempt_roles=[EXEMPT_ROLE]), + rule("BLOCK", company=COMPANY, action=BLOCK_ON_SAVE), + ] + verdict = decide(rules, 30, [EXEMPT_ROLE]) + self.assertEqual((verdict.outcome, hit_names(verdict)), (BLOCK, ["BLOCK"])) + + def test_the_warning_lists_only_the_triggered_rules(self): + rules = [rule("WARN", threshold_days=20), rule("NOT YET", company=COMPANY, threshold_days=40)] + verdict = decide(rules, 30, SALES_ROLES) + self.assertEqual((verdict.outcome, hit_names(verdict)), (WARN, ["WARN"])) + + +class TestMessage(FrappeTestCase): + def render(self, action, user_roles): + verdict = decide([rule(action=action, exempt_roles=[EXEMPT_ROLE])], 30, user_roles) + return message(verdict, CUSTOMER_NAME, INVOICE, 30) + + def test_a_blocked_user_is_told_which_roles_can_continue(self): + self.assertIn(EXEMPT_ROLE, self.render(BLOCK_ON_SAVE, SALES_ROLES)) + + def test_an_exempt_user_is_told_their_role_lets_them_continue(self): + text = self.render(BLOCK_ON_SAVE, [EXEMPT_ROLE]) + self.assertIn("Your role lets you continue.", text) + self.assertNotIn(EXEMPT_ROLE, text) + + def test_a_warn_rule_ignores_the_exempt_roles_the_form_hides(self): + for user_roles in (SALES_ROLES, [EXEMPT_ROLE]): + with self.subTest(user_roles=user_roles): + text = self.render(WARN_ON_SAVE, user_roles) + self.assertNotIn(EXEMPT_ROLE, text) + self.assertNotIn("Your role lets you continue.", text) diff --git a/optima_payment/overdue_limit/tests/test_limit_integration.py b/optima_payment/overdue_limit/tests/test_limit_integration.py new file mode 100644 index 0000000..36820bb --- /dev/null +++ b/optima_payment/overdue_limit/tests/test_limit_integration.py @@ -0,0 +1,161 @@ +# Copyright (c) 2026, IT Systematic and Contributors +# See license.txt + +"""The Time Based Overdue Limit through real saves and submits of Sales Invoices and Sales Orders. + +The branch matrix and the message text are in test_limit.py. These prove what only a real document +shows: each action is checked at its own hook on both doctypes (Warn on Save at validate, so at +submit too; Block on Save at before_save only; Block on Submit at before_submit only), the acting +user's roles decide the exemption, credit notes pass, the group tree is read from the database, and +only a payment allocated to the invoice lifts the block. + +Each test starts from a customer whose Sales Invoice fell due 25 days ago and that ERPNext's daily +status job has marked Overdue. +""" + +import erpnext +import frappe +from frappe.tests.utils import FrappeTestCase + +from optima_payment.overdue_limit.limit import BLOCK_ON_SAVE, BLOCK_ON_SUBMIT, WARN_ON_SAVE, OverdueLimitError +from optima_payment.tests.utils import ( + get_or_create_customer, + get_or_create_user, + make_credit_note, + make_invoice_overdue, + make_invoice_payment, + make_overdue_limit, + make_sales_invoice, + make_sales_order, +) + +CUSTOMER = "Optima Overdue Test Customer" +SALES_ROLES = ["Sales User", "Sales Manager", "Accounts User"] +EXEMPT_ROLE = "Accounts Manager" +SALES_DOCUMENTS = {"Sales Invoice": make_sales_invoice, "Sales Order": make_sales_order} + + +class TestOverdueLimitIntegration(FrappeTestCase): + def setUp(self): + self.company = erpnext.get_default_company() + self.customer = get_or_create_customer(CUSTOMER) + self.overdue_invoice = make_sales_invoice(customer=self.customer) + make_invoice_overdue(self.overdue_invoice.name, days=25) + frappe.clear_messages() + + def tearDown(self): + frappe.set_user("Administrator") + frappe.db.rollback() + + def make_draft(self, doctype="Sales Invoice"): + return SALES_DOCUMENTS[doctype](customer=self.customer, do_not_submit=True) + + def overdue_messages(self) -> list: + return [msg for msg in frappe.get_message_log() if self.overdue_invoice.name in msg.message] + + def act_as(self, email, roles): + frappe.set_user(get_or_create_user(email, roles)) + + def submit(self, doc): + doc.flags.ignore_permissions = True + doc.submit() + + # ============================================================================================ + # ACTIONS + + def test_warn_on_save_saves_the_draft_with_a_warning(self): + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE) + for doctype in SALES_DOCUMENTS: + with self.subTest(doctype): + frappe.clear_messages() + draft = self.make_draft(doctype) + self.assertFalse(draft.is_new()) + self.assertTrue(self.overdue_messages()) + + def test_warn_on_save_warns_again_at_submit(self): + """Frappe runs validate at submit too.""" + make_overdue_limit(party=self.customer, action=WARN_ON_SAVE) + draft = self.make_draft() + frappe.clear_messages() + self.submit(draft) + self.assertEqual(draft.docstatus, 1) + self.assertTrue(self.overdue_messages()) + + def test_block_on_save_refuses_the_draft(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + for doctype in SALES_DOCUMENTS: + with self.subTest(doctype), self.assertRaises(OverdueLimitError): + self.make_draft(doctype) + + def test_block_on_save_lets_a_draft_saved_before_the_rule_submit(self): + draft = self.make_draft() + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + self.submit(draft) + self.assertEqual(draft.docstatus, 1) + + def test_block_on_submit_saves_the_draft_silently_and_refuses_the_submit(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SUBMIT) + for doctype in SALES_DOCUMENTS: + with self.subTest(doctype): + frappe.clear_messages() + draft = self.make_draft(doctype) + self.assertFalse(self.overdue_messages()) + with self.assertRaises(OverdueLimitError): + self.submit(draft) + + # ============================================================================================ + # WHO AND WHAT IS LET THROUGH + + def test_the_acting_users_roles_decide_the_exemption(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE, exempt_roles=[EXEMPT_ROLE]) + + self.act_as("optima.overdue.sales@example.com", SALES_ROLES) + with self.assertRaises(OverdueLimitError): + self.make_draft() + + self.act_as("optima.overdue.exempt@example.com", [*SALES_ROLES, EXEMPT_ROLE]) + draft = self.make_draft() + self.submit(draft) + self.assertEqual(draft.docstatus, 1) + + def test_a_credit_note_saves_and_submits_under_both_blocks(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + make_overdue_limit(party=self.customer, action=BLOCK_ON_SUBMIT) + credit_note = make_credit_note(self.overdue_invoice.name) + credit_note.insert(ignore_permissions=True) + self.submit(credit_note) + self.assertEqual(credit_note.docstatus, 1) + + def test_a_rule_on_an_ancestor_group_applies(self): + group = frappe.db.get_value("Customer", self.customer, "customer_group") + parent_group = frappe.db.get_value("Customer Group", group, "parent_customer_group") + make_overdue_limit(applies_to="Customer Group", party=parent_group, action=BLOCK_ON_SAVE) + with self.assertRaises(OverdueLimitError): + self.make_draft() + + def test_a_rule_for_another_company_does_not_apply(self): + other_company = frappe.db.get_value("Company", {"name": ("!=", self.company)}, "name") + if not other_company: + self.skipTest("the site has a single company") + + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE, company=other_company) + self.assertFalse(self.make_draft().is_new()) + + # ============================================================================================ + # LIFTING THE BLOCK + + def test_paying_the_overdue_invoice_lifts_the_block(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + make_invoice_payment(self.overdue_invoice.name) + self.assertFalse(self.make_draft().is_new()) + + def test_an_unallocated_payment_covering_the_debt_keeps_the_block(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE) + make_invoice_payment(self.overdue_invoice.name, allocate=False) + with self.assertRaises(OverdueLimitError): + self.make_draft() + + def test_a_disabled_rule_does_nothing(self): + make_overdue_limit(party=self.customer, action=BLOCK_ON_SAVE, enabled=0) + self.assertFalse(self.make_draft().is_new()) + self.assertFalse(self.overdue_messages()) diff --git a/optima_payment/tests/utils.py b/optima_payment/tests/utils.py index c769f15..93ad5d6 100644 --- a/optima_payment/tests/utils.py +++ b/optima_payment/tests/utils.py @@ -6,14 +6,18 @@ Setting, Bank, Account, Cost Center, Project, Customer/Supplier, and a reference Sales/Purchase Order with its own taxes table pre-populated to dodge this site's KSA/ZATCA mandatory-field customizations), plus Purchases Bulk Tool -documents with their purchase taxes template, and the party Journal Entries -and users the statement of account needs. Tests import these factories +documents with their purchase taxes template, the party Journal Entries +and users the statement of account needs, and the Sales Invoices, payments +and rules the Time Based Overdue Limit needs. Tests import these factories instead of constructing documents inline. """ import frappe import erpnext +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.party import get_party_account +from erpnext.controllers.accounts_controller import update_invoice_status from frappe.utils import add_days, nowdate from optima_payment.override.doctype_class.payment_entry import CustomPaymentEntry @@ -158,6 +162,7 @@ def get_or_create_customer(customer_name: str = "Optima Test Customer") -> str: # customer_type=Individual dodges KSA site customizations that make # registration_type / registration_value / tax_id mandatory for Companies. "customer_type": "Individual", + "tax_category": get_test_tax_category(), # Site-specific mandatory custom field; the key is ignored where it doesn't exist. "customer_name_in_arabic": customer_name, } @@ -166,9 +171,9 @@ def get_or_create_customer(customer_name: str = "Optima Test Customer") -> str: def get_test_tax_category() -> str | None: - """The Tax Category test suppliers carry, and so the category of every voucher made for them. + """The Tax Category test parties carry, and so the category of every voucher made for them. - KSA/ZATCA customizations on this site make tax_category mandatory on Supplier. + KSA/ZATCA customizations on some sites make tax_category mandatory on Customer and Supplier. """ return frappe.db.get_value("Tax Category", {}, "name") @@ -271,33 +276,56 @@ def get_item_tax_charge() -> tuple[str, float]: # REFERENCE SALES / PURCHASE ORDERS -def make_reference_sales_order(company: str, customer: str) -> str: - """Create and submit a minimal Sales Order to use as a Bank Guarantee-BG reference.""" - item = get_or_create_item() +def get_sales_lines(company: str) -> dict: + """One item line and the pre-seeded taxes row of a Sales Order or Invoice, on the test cost center.""" cost_center = get_or_create_cost_center(company) account_head, tax_rate = get_item_tax_charge() + return { + "items": [{"item_code": get_or_create_item(), "qty": 1, "rate": 100, "cost_center": cost_center}], + "taxes": [ + { + "charge_type": "On Net Total", + "account_head": account_head, + "rate": tax_rate, + "cost_center": cost_center, + "description": account_head, + } + ], + } + + +def make_sales_order( + *, + customer: str, + company: str | None = None, + do_not_insert: bool = False, + do_not_submit: bool = False, + **overrides, +) -> frappe.model.document.Document: + company = company or erpnext.get_default_company() so = frappe.get_doc( { "doctype": "Sales Order", "company": company, "customer": customer, - "cost_center": cost_center, + "cost_center": get_or_create_cost_center(company), "delivery_date": nowdate(), - "items": [{"item_code": item, "qty": 1, "rate": 100, "cost_center": cost_center}], - "taxes": [ - { - "charge_type": "On Net Total", - "account_head": account_head, - "rate": tax_rate, - "cost_center": cost_center, - "description": account_head, - } - ], + **get_sales_lines(company), + **overrides, } ) + if do_not_insert: + return so + so.insert(ignore_permissions=True) - so.submit() - return so.name + if not do_not_submit: + so.submit() + return so + + +def make_reference_sales_order(company: str, customer: str) -> str: + """Create and submit a minimal Sales Order to use as a Bank Guarantee-BG reference.""" + return make_sales_order(customer=customer, company=company).name def make_reference_purchase_order(company: str, supplier: str) -> str: @@ -798,3 +826,114 @@ def get_or_create_user(email: str, roles: list[str]) -> str: user.set("roles", [{"role": role} for role in roles]) user.save(ignore_permissions=True) return email + + +# ==================================================================================================== +# TIME BASED OVERDUE LIMIT FACTORIES + + +def make_sales_invoice( + *, + customer: str, + company: str | None = None, + posting_date: str | None = None, + due_date: str | None = None, + do_not_insert: bool = False, + do_not_submit: bool = False, + **overrides, +) -> frappe.model.document.Document: + """A one-line Sales Invoice, due on ``posting_date`` unless ``due_date`` says otherwise. + + set_posting_time keeps a past posting date, which validate would otherwise reset to today. The + header has no cost center: a payment copies it onto balance sheet lines, which some sites refuse. + """ + company = company or erpnext.get_default_company() + posting_date = posting_date or nowdate() + si = frappe.get_doc( + { + "doctype": "Sales Invoice", + "company": company, + "customer": customer, + "set_posting_time": 1, + "posting_date": posting_date, + "due_date": due_date or posting_date, + **get_sales_lines(company), + **overrides, + } + ) + if do_not_insert: + return si + + si.insert(ignore_permissions=True) + if not do_not_submit: + si.submit() + return si + + +def make_invoice_overdue(invoice: str, days: int) -> None: + """Move the due date of ``invoice`` and of its payment schedule ``days`` into the past, then run + ERPNext's daily status job, which marks it Overdue as it would on a live site. + + Only the due dates move, not the posting date, so the invoice stays in the fiscal year even in + January (CI's is the calendar year). Validate refuses a due date before the posting date, hence + the direct writes. + """ + due_date = add_days(nowdate(), -days) + frappe.db.set_value("Sales Invoice", invoice, "due_date", due_date) + frappe.db.set_value("Payment Schedule", {"parenttype": "Sales Invoice", "parent": invoice}, "due_date", due_date) + update_invoice_status() + + +def make_credit_note(invoice: str) -> frappe.model.document.Document: + """An unsaved full return of ``invoice``.""" + credit_note = make_sales_return(invoice) + # Site-specific field, mandatory on returns; ignored where it doesn't exist. + credit_note.reason_for_issuance = "Optima test return" + return credit_note + + +def make_invoice_payment(invoice: str, *, allocate: bool = True) -> frappe.model.document.Document: + """A submitted Payment Entry receiving the whole outstanding amount of ``invoice``. + + Without ``allocate`` the payment names no invoice, so ``invoice`` stays outstanding while the + customer owes nothing. + """ + company = frappe.db.get_value("Sales Invoice", invoice, "company") + pe = get_payment_entry("Sales Invoice", invoice, bank_account=get_payment_entry_account(company)) + if not allocate: + pe.set("references", []) + pe.reference_no = "OPTIMA-TEST" + pe.reference_date = nowdate() + # Some sites make remarks mandatory on Payment Entry. + pe.remarks = "Optima test payment" + pe.insert(ignore_permissions=True) + pe.submit() + return pe + + +def make_overdue_limit( + *, + party: str, + action: str, + threshold_days: int = 20, + applies_to: str = "Customer", + company: str | None = None, + exempt_roles: list[str] | None = None, + enabled: int = 1, + do_not_insert: bool = False, +) -> frappe.model.document.Document: + rule = frappe.get_doc( + { + "doctype": "Time Based Overdue Limit", + "enabled": enabled, + "applies_to": applies_to, + "party": party, + "company": company, + "action": action, + "threshold_days": threshold_days, + "exempt_roles": [{"role": role} for role in exempt_roles or []], + } + ) + if not do_not_insert: + rule.insert(ignore_permissions=True) + return rule