From 32baf6a47ce6853fb28fefd91acc339d903e27b7 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:52:30 +0530 Subject: [PATCH 01/12] fix: force-delete repost data file during cleanup (backport #57245) --- .../repost_item_valuation.py | 2 +- .../test_repost_item_valuation.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 160a887d851..d0e1cac2b23 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -242,7 +242,7 @@ class RepostItemValuation(Document): def clear_attachment(self): if attachments := get_attachments(self.doctype, self.name): attachment = attachments[0] - frappe.delete_doc("File", attachment.name, ignore_permissions=True) + frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True) if self.reposting_data_file: self.db_set("reposting_data_file", None) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index a27c8d49ea2..a941b64856a 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin): ) ) + def test_clear_attachment_skips_referenced_data_file(self): + riv = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "company": "_Test Company", + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "posting_date": today(), + } + ).insert(ignore_permissions=True) + + attached = frappe.get_doc( + { + "doctype": "File", + "file_name": "repost_data.json.gz", + "content": "test", + "attached_to_doctype": riv.doctype, + "attached_to_name": riv.name, + } + ).insert(ignore_permissions=True) + riv.db_set("reposting_data_file", attached.file_url) + + riv.clear_attachment() + + self.assertFalse(frappe.db.exists("File", attached.name)) + self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file")) + @ERPNextTestSuite.change_settings( "Stock Reposting Settings", {"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2}, From 95de2374ede856f03faee99b740c8f2cf2a3f56d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 14:58:11 +0530 Subject: [PATCH 02/12] fix: get reserved batch qty precision from settings --- erpnext/controllers/stock_controller.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 62384f3a85a..3e9803888f0 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1371,8 +1371,9 @@ class StockController(AccountsController): if outstanding > 0: reservations[key].append(row) + precision = frappe.get_precision("Serial and Batch Entry", "qty") for (batch_no, warehouse), reserved_qty in outstanding_qty.items(): - if flt(reserved_qty, 6) <= 0: + if flt(reserved_qty, precision) <= 0: continue batch_qty = get_batch_qty( @@ -1383,7 +1384,7 @@ class StockController(AccountsController): consider_negative_batches=True, ) - if flt(batch_qty, 6) >= flt(reserved_qty, 6): + if flt(batch_qty, precision) >= flt(reserved_qty, precision): continue vouchers = ", ".join( From c587f4934a5d3685d5bd7c636acf2b090ee7d720 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 22 Jul 2026 17:58:38 +0530 Subject: [PATCH 03/12] refactor: move new-doc route options to StockController set_route_options_for_new_doc lived in TransactionController, so doctypes extending StockController directly (Stock Reconciliation, Stock Entry) missed the Batch/SABB prefill or duplicated it locally. Move it to StockController and call it from onload_post_render so all descendants inherit it. - Batch quick entry from Stock Reconciliation items now prefills Item - SABB route options unified: warehouse || s_warehouse || t_warehouse, so transaction doctypes now also prefill warehouse - Stock Entry's duplicate handler removed; its onload_post_render now calls super (cherry picked from commit 551559e804de16cc633306575e042d4d2e576019) --- .../public/js/controllers/stock_controller.js | 30 +++++++++++++++++++ erpnext/public/js/controllers/transaction.js | 28 ----------------- .../stock/doctype/stock_entry/stock_entry.js | 25 +--------------- .../stock_reconciliation.js | 11 ------- 4 files changed, 31 insertions(+), 63 deletions(-) diff --git a/erpnext/public/js/controllers/stock_controller.js b/erpnext/public/js/controllers/stock_controller.js index a205412e75d..eef7f2f0a37 100644 --- a/erpnext/public/js/controllers/stock_controller.js +++ b/erpnext/public/js/controllers/stock_controller.js @@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con } } + onload_post_render() { + this.set_route_options_for_new_doc(); + } + + set_route_options_for_new_doc() { + // While creating a Batch or Serial and Batch Bundle from the link + // field, copy details from the line item to the new form + if (!this.frm.fields_dict.items) return; + + let batch_no_field = this.frm.get_docfield("items", "batch_no"); + if (batch_no_field) { + batch_no_field.get_route_options_for_new_doc = (row) => { + return { + item: row.doc.item_code, + }; + }; + } + + let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle"); + if (sbb_field) { + sbb_field.get_route_options_for_new_doc = (row) => { + return { + item_code: row.doc.item_code, + warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse, + voucher_type: this.frm.doc.doctype, + }; + }; + } + } + barcode(doc, cdt, cdn) { let row = locals[cdt][cdn]; if (row.barcode) { diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 63d450de221..ad110712d71 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe erpnext.toggle_serial_batch_fields(this.frm); } - set_route_options_for_new_doc() { - // While creating the batch from the link field, copy item from line item to batch form - - if (this.frm.fields_dict["items"].grid.get_field("batch_no")) { - let batch_no_field = this.frm.get_docfield("items", "batch_no"); - if (batch_no_field) { - batch_no_field.get_route_options_for_new_doc = function (row) { - return { - item: row.doc.item_code, - }; - }; - } - } - - // While creating the SABB from the link field, copy item, doctype from line item to SABB form - if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) { - let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle"); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = (row) => { - return { - item_code: row.doc.item_code, - voucher_type: this.frm.doc.doctype, - }; - }; - } - } - } - scan_barcode() { frappe.flags.dialog_set = false; this.barcode_scanner.process_scan(); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index c627c6bbdb1..94ce0652931 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } - frm.events.set_route_options_for_new_doc(frm); - frm.set_df_property( "items", "cannot_add_rows", @@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", { ); }, - set_route_options_for_new_doc(frm) { - let batch_no_field = frm.get_docfield("items", "batch_no"); - if (batch_no_field) { - batch_no_field.get_route_options_for_new_doc = function (row) { - return { - item: row.doc.item_code, - }; - }; - } - - let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle"); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = (row) => { - return { - item_code: row.doc.item_code, - voucher_type: frm.doc.doctype, - warehouse: row.doc.s_warehouse || row.doc.t_warehouse, - }; - }; - } - }, - get_items_from_transit_entry: function (frm) { if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) { frm.add_custom_button( @@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle } onload_post_render() { + super.onload_post_render(); var me = this; if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) { me.company(); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index ef4672899cc..e711d7248f7 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", { }; }); - let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle"); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = (row) => { - return { - item_code: row.doc.item_code, - warehouse: row.doc.warehouse, - voucher_type: frm.doc.doctype, - }; - }; - } - if (frm.doc.company) { erpnext.queries.setup_queries(frm, "Warehouse", function () { return erpnext.queries.warehouse(frm.doc); From 1132eb1a0f9b4c2853c095edd61156c7186d4787 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:48:45 +0530 Subject: [PATCH 04/12] fix: respect selected BOM when creating work order for variant item (#57359) * fix: respect selected BOM when creating work order for variant item * fix: add type hints to make_work_order --- .../manufacturing/doctype/work_order/work_order.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index e17d94d4239..eecf06b15c4 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True): @frappe.whitelist() def make_work_order( - bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None + bom_no: str, + item: str, + qty: float = 0, + company: str | None = None, + project: str | None = None, + variant_items: str | list | None = None, + use_multi_level_bom: bool | None = None, ): from erpnext import get_default_company @@ -2563,7 +2569,8 @@ def make_work_order( item_details = get_item_details(item, project) - if frappe.db.get_value("Item", item, "variant_of"): + # selected BOM already belongs to this variant — keep it + if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item: if variant_bom := frappe.db.get_value( "BOM", {"item": item, "is_default": 1, "docstatus": 1}, From 84813d7f46d245e6f97f05edf2b50afc057a3585 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:08:20 +0000 Subject: [PATCH 05/12] fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (backport #57380) (#57397) * fix: Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime (#57380) * fix: shift same-timestamp sibling SLEs when cancelling an entry update_qty_in_future_sle compared against the reversal SLE's own creation and skipped same-posting_datetime siblings on cancel, leaving their qty_after_transaction stale and causing false negative stock errors. * fix: revert update_qty_in_future_sle cancel tie-break, it double-counted (cherry picked from commit 8c0ec3c179538eb15cbae26384a54de216f52a20) # Conflicts: # erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py * chore: fix conflicts Fix test case for cancelling stock ledger entries with the same timestamp to ensure correct behavior. * chore: fix conflicts --------- Co-authored-by: rohitwaghchaure --- .../test_stock_ledger_entry.py | 41 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 17 +++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 0ff773cd6e7..0491498b1b2 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin): # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). self.assertEqual(qty_after(receipt2), 10) + def test_cancel_shifts_same_timestamp_delivery_notes(self): + item = make_item().name + warehouse = "_Test Warehouse - _TC" + posting_date = today() + posting_time = "10:00:00" + + make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date=posting_date, + posting_time="09:00:00", + ) + + dns = [] + for i in range(5): + dns.append( + create_delivery_note( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10 * i, + posting_date=posting_date, + posting_time=posting_time, + ) + ) + time.sleep(1) + + dn = dns[2] + dn.cancel() + + expected_qty_after_transaction_of_dns3 = 40 + qty_after_transaction_of_dns3 = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": dns[3].name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7f6cc176373..23e99899068 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -497,7 +497,9 @@ class update_entries_after: self.data = frappe._dict() - if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"): + if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get( + "cancelled" + ): self.initialize_previous_data(self.args) self.build() @@ -805,10 +807,23 @@ class update_entries_after: def process_sle_against_current_timestamp(self): sl_entries = get_sle_against_current_voucher(self.args) + if self.args.get("cancelled") and sl_entries: + self.seed_previous_sle_for_cancellation(sl_entries[0]) for sle in sl_entries: sle["timestamp"] = sle.posting_datetime self.process_sle(sle) + def seed_previous_sle_for_cancellation(self, anchor_sle): + key = (anchor_sle.item_code, anchor_sle.warehouse) + if key in self.prev_sle_dict: + return + + args = frappe._dict(anchor_sle) + args["sle_id"] = args.name + prev_sle = get_previous_sle_of_current_voucher(args) + if prev_sle: + self.prev_sle_dict[key] = prev_sle + def get_future_entries_to_fix(self): # includes current entry! args = self.data[self.args.warehouse].previous_sle or frappe._dict( From bbd942c600cf5957f2ee3ffece2c6f1155548743 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:42:28 +0530 Subject: [PATCH 06/12] feat: make Shipping Rule Cost Center optional with company default fallback (backport #57355) (#57385) feat: make Shipping Rule Cost Center optional with company default fallback (#57355) feat(shipping-rule): make cost center optional with company default fallback Cost Center on Shipping Rule is no longer mandatory. When left blank, the applied shipping tax row falls back to the company default cost center, avoiding the 'Cost Center is required for Profit and Loss account' error on submit. The rule's project is also applied to the tax row. (cherry picked from commit a47f25896b095aea90308801a8fa91f77fc0c759) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- .../doctype/shipping_rule/shipping_rule.json | 17 +++++++++-------- .../doctype/shipping_rule/shipping_rule.py | 7 +++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json index bec4b94b82d..e8070588564 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json @@ -82,8 +82,7 @@ "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", - "options": "Cost Center", - "reqd": 1 + "options": "Cost Center" }, { "fieldname": "shipping_amount_section", @@ -141,19 +140,20 @@ "fieldtype": "Column Break" }, { - "fieldname": "project", - "fieldtype": "Link", - "label": "Project", - "options": "Project" + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } ], "icon": "fa fa-truck", "idx": 1, "links": [], - "modified": "2024-03-27 13:10:41.653314", + "modified": "2026-07-22 14:53:27.315435", "modified_by": "Administrator", "module": "Accounts", "name": "Shipping Rule", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -197,7 +197,8 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 7b226560668..e13ab9817e3 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -36,18 +36,17 @@ class ShippingRule(Document): from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import ( ShippingRuleCondition, ) - from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ( - ShippingRuleCountry, - ) + from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry account: DF.Link calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"] company: DF.Link conditions: DF.Table[ShippingRuleCondition] - cost_center: DF.Link + cost_center: DF.Link | None countries: DF.Table[ShippingRuleCountry] disabled: DF.Check label: DF.Data + project: DF.Link | None shipping_amount: DF.Currency shipping_rule_type: DF.Literal["Selling", "Buying"] # end: auto-generated types From 8a505727865030dfa283f6aa28104f732c7c3b76 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 15:56:42 +0530 Subject: [PATCH 07/12] fix: rebalance batch slot values at the pooled rate when driven negative A batch is one valuation pool, so consumption is valued at the pooled rate while slots may carry stale intra-batch detail (e.g. units reconciled at zero and later merged). Consuming such a slot leaves a negative value on positive qty. Spread the pool value across the batch's slots when that happens; non-batchwise slots pool per warehouse. --- .../stock/report/stock_ageing/stock_ageing.py | 28 ++++++++++ .../report/stock_ageing/test_stock_ageing.py | 52 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index e880e8db9b9..55a07835436 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -325,6 +325,7 @@ class FIFOSlots: del stock_ledger_entries self._recompute_moving_average_slots() + self._rebalance_negative_batch_slots() if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) @@ -346,6 +347,33 @@ class FIFOSlots: if is_qty_slot(slot): slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _rebalance_negative_batch_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_batch_no"): + self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"]) + + def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None: + """A batch is one valuation pool, so a slot driven negative by consumption + at the pooled rate is stale detail: spread the pool value over its slots.""" + groups = {} + for slot in fifo_queue: + if is_batch_slot(slot): + key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None + groups.setdefault(key, []).append(slot) + + for slots in groups.values(): + has_negative_slot = any( + flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0 + for slot in slots + ) + total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots) + if not has_negative_slot or total_qty <= 0: + continue + + rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty + for slot in slots: + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index f072dfeba4d..640c10e35c3 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -616,6 +616,58 @@ class TestStockAgeing(ERPNextTestSuite): ], ) + def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self): + """Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5] + Consuming the zero-valued head slot at the pooled rate drives it + negative; slot values are then rebalanced to the batch pool rate.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batch Pool Rebalance", + {"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"}, + ).name + + batch_no = "SA-POOL-REBALANCE-BATCH" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert( + ignore_permissions=True + ) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference): + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + ) + + sle = [ + make_sle("2021-12-01", "001", 10, 10, 0), + make_sle("2021-12-02", "002", 10, 20, 100), + make_sle("2021-12-03", "003", -4, 16, -20), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots[item_code]["fifo_queue"] + + self.assertEqual( + queue, + [ + [batch_no, 1, 6.0, "2021-12-01", 30.0], + [batch_no, 1, 10.0, "2021-12-01", 50.0], + ], + ) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From 2e6b4b5838960c090e1c92a37d15fc0444320582 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 23 Jul 2026 14:06:23 +0530 Subject: [PATCH 08/12] fix: guard against missing is_your_company_address custom field on address (cherry picked from commit ea3ed8b836e933e32fee0f345c77fa24d42576e1) --- erpnext/accounts/custom/address.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index 5ea8b1d1ca8..8f57489a82f 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -17,7 +17,7 @@ class ERPNextAddress(Address): def link_address(self): """Link address based on owner""" - if self.is_your_company_address: + if self.get("is_your_company_address"): return return super().link_address() @@ -28,7 +28,9 @@ class ERPNextAddress(Address): self.is_your_company_address = 1 def validate_reference(self): - if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]: + if self.get("is_your_company_address") and not [ + row for row in self.links if row.link_doctype == "Company" + ]: frappe.throw( _( "Address needs to be linked to a Company. Please add a row for Company in the Links table." From bbe7580c9d5e5027f1a7ef51138260d9afc351e5 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 23 Jul 2026 17:43:57 +0530 Subject: [PATCH 09/12] fix: typeerror in get_batches_by_oldest for mixed batch expiry sort on (expiry is none, expiry) so a null expiry_date is never order-compared against a datetime.date, which raised typeerror in python 3 when a warehouse held both dated and never-expiring batches. (cherry picked from commit 62c9f8ee3e7f8a6452f4bab9e5cc58f699f41e2d) --- erpnext/stock/doctype/batch/batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 5b8f9eade10..dc73e9ffddf 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse): """Returns the oldest batch and qty for the given item_code and warehouse""" batches = get_batch_qty(item_code=item_code, warehouse=warehouse) batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches] - batches_dates.sort(key=lambda tup: tup[1]) + batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1])) return batches_dates From 3f6501b4ffaffa85d4d704d1483a8c22bdbb5ac6 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 24 Jul 2026 11:12:15 +0530 Subject: [PATCH 10/12] fix: map pick list customer to delivery note when no sales order backport of #57412 --- erpnext/stock/doctype/pick_list/pick_list.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 3e0c414e216..5fd8f333de7 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None): target_doc.company = pick_list.company + if not target_doc.customer: + target_doc.customer = pick_list.customer + item_table_mapper_without_so = { "doctype": f"{target} Item", "field_map": { From c79f2e45a8d7e25e1e25f1f73cf08ba9ab82761f Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 24 Jul 2026 12:22:08 +0530 Subject: [PATCH 11/12] fix: respect user permissions in party dashboard company list use frappe.get_list instead of frappe.get_all in get_dashboard_info so the company list honors user permissions. previously, a party with invoices across multiple companies would raise "User don't have permissions to select/read this account" for users restricted to a subset of companies, since get_party_account was called for companies the user could not access. fixes frappe/erpnext#57428 (cherry picked from commit 903c87bcaa705ca997e28ec53132e308502ac6eb) --- erpnext/accounts/party.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 7b726604d8e..588c544b7cd 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" - companies = frappe.get_all( + companies = frappe.get_list( doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"] ) From d3c5e866f0049b2b627fe704fd9ef55edbfd8d5c Mon Sep 17 00:00:00 2001 From: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:33:26 +0530 Subject: [PATCH 12/12] feat: block sales invoice submit when customer overdue exceeds threshold (backport #57230, #57298) (#57438) * feat: block sales invoice submit when customer overdue exceeds threshold (#57230) * feat: block sales invoice submit when customer overdue exceeds threshold Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in Accounts Settings, submitting a Sales Invoice is blocked if the customer's overdue amount exceeds their threshold, unless the current user holds a configured bypass role. Modeled on the existing credit limit feature. - Accounts Settings (Credit Limits tab): enable toggle + bypass role. - Per-customer threshold on the Customer Credit Limit table, shown only when the feature is enabled via a property setter (same mechanism as subscription / accounting dimension sections). Table relabeled to "Credit & Overdue Limits". - Overdue is read live from the ledger via get_outstanding_invoices (payments already netted), summing Sales Invoices past their due date. - Enforced in Sales Invoice on_submit, after the credit-limit check; returns are exempt. - validate_credit_limit_on_change no longer trips when a row sets only the overdue threshold (credit_limit = 0). Fixes #52960 * fix: compute overdue amount in company currency and format with fmt_money get_customer_overdue_amount now sums GL Entry debit - credit grouped per invoice, which is always booked in company currency, instead of using get_outstanding_invoices which returns the receivable-account currency. The threshold is in company currency, so the previous comparison could mix currencies for customers with a foreign-currency receivable account. This mirrors how get_customer_outstanding computes the figure for the existing credit-limit check. The blocking message now formats both amounts with fmt_money using the company currency. Adds a test asserting a 100 USD invoice at a conversion rate of 50 is counted as 5000 in company currency. * refactor: drop redundant threshold coercion and dead test cleanup - Coerce the overdue threshold with flt() once when reading it, instead of calling flt() on it at each of the three use sites. - Remove a no-op set_overdue_billing_threshold() call in the feature-disabled block (the threshold was already set to that value) and the trailing reset, which is dead since each test is rolled back. No behaviour change. * fix: compute overdue amount from payment terms, matching the Overdue status The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets to the LAST payment term. An invoice whose first term was past due and unpaid was therefore counted as zero, even though ERPNext already shows it as Overdue in the invoice list. The gate and the UI could disagree. get_customer_overdue_amount now follows the same rule as is_overdue(): per invoice, the amount that has fallen due (sum of payment schedule terms past their due date) minus what has been paid, clamped to the outstanding balance. Invoices without a schedule (POS, opening) still fall back to the invoice due date, mirroring is_overdue()'s own guard. The ledger stays the source of truth for what is unpaid: the outstanding per invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is always stored in company currency, so no currency conversion is needed and the comparison against the threshold stays consistent. Adds a test covering a two-term invoice: only the past-due term counts, and paying it off clears the overdue amount. * feat: honour the overdue billing threshold set on the customer group The threshold lives on Customer Credit Limit, which is also rendered on Customer Group. A threshold set there was stored but never evaluated, so the configuration was a silent no-op. get_overdue_billing_threshold now reads the customer's row and falls back to its customer group, mirroring get_credit_limit. The group's bypass_credit_limit_check is deliberately not consulted: it is labelled for the credit limit check at sales order and is unrelated to overdue billing. get_customer_group_details also dropped the threshold when copying group rows onto a customer, because it copied a single hardcoded field per table. It now copies a list of fields per table, so credit_limit and overdue_billing_threshold both carry over. * refactor: clearer labels for the overdue billing control (#57298) refactor: clearer labels and messages, drop "threshold" wording User-facing text only, no field or behaviour changes: - Accounts Settings toggle label -> "Restrict Customer Over Billing". - Bypass role label -> "Role Allowed to Bypass Over Billing Restriction". - Customer Credit Limit field label -> "Overdue Limit". - Rewrote the descriptions and the block message to match and to stop saying "threshold". * fix: treat zero overdue limit as opt-out and isolate settings in test get_overdue_billing_threshold treated an explicit 0 on the customer's credit limit row as "not set" and fell back to the customer group. A customer could not be exempted from the group restriction while keeping a credit limit row, and every existing row defaults to 0, so enabling the feature on a group blocked all its customers that had any credit limit row. Guard the group fallback on "threshold is None" (no row for the company) instead of a falsy check, so an explicit 0 acts as an opt-out. test_overdue_billing_threshold_on_submit mutated the Accounts Settings singleton without restoring it, so a failed assertion mid-test leaked enable_overdue_billing_threshold and the bypass role into later tests that submit sales invoices. Wrap the mutations in try/finally and restore the originals. * fix: let a zero customer overdue limit inherit the group's limit A 0 on the customer's credit limit row falls back to the customer group again; only a non-zero value on the customer overrides the group. Reverts the earlier opt-out interpretation and updates the fallback test to expect the group's limit. --- .../accounts_settings/accounts_settings.json | 17 ++ .../accounts_settings/accounts_settings.py | 10 ++ .../doctype/sales_invoice/sales_invoice.py | 6 + .../selling/doctype/customer/customer.json | 4 +- erpnext/selling/doctype/customer/customer.py | 133 ++++++++++++++- .../selling/doctype/customer/test_customer.py | 156 +++++++++++++++++- .../customer_credit_limit.json | 10 ++ .../customer_credit_limit.py | 1 + .../customer_group/customer_group.json | 2 +- 9 files changed, 330 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 9381ad2ff18..27806857fd6 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -75,6 +75,8 @@ "over_billing_allowance", "credit_controller", "role_allowed_to_over_bill", + "enable_overdue_billing_threshold", + "role_allowed_to_bypass_overdue_billing", "column_break_11", "assets_tab", "asset_settings_section", @@ -271,6 +273,21 @@ "label": "Role Allowed to over bill ", "options": "Role" }, + { + "default": "0", + "description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.", + "fieldname": "enable_overdue_billing_threshold", + "fieldtype": "Check", + "label": "Restrict Customer Over Billing" + }, + { + "depends_on": "eval:doc.enable_overdue_billing_threshold", + "description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.", + "fieldname": "role_allowed_to_bypass_overdue_billing", + "fieldtype": "Link", + "label": "Role Allowed to Bypass Over Billing Restriction", + "options": "Role" + }, { "fieldname": "period_closing_settings_section", "fieldtype": "Section Break" diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index e76281687ab..f1b9aaa2f87 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -77,6 +77,7 @@ class AccountsSettings(Document): enable_fuzzy_matching: DF.Check enable_immutable_ledger: DF.Check enable_loyalty_point_program: DF.Check + enable_overdue_billing_threshold: DF.Check enable_party_matching: DF.Check enable_subscription: DF.Check exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] @@ -95,6 +96,7 @@ class AccountsSettings(Document): receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int repost_allowed_types: DF.Table[RepostAllowedTypes] + role_allowed_to_bypass_overdue_billing: DF.Link | None role_allowed_to_over_bill: DF.Link | None role_to_notify_on_depreciation_failure: DF.Link | None role_to_override_stop_action: DF.Link | None @@ -150,6 +152,10 @@ class AccountsSettings(Document): toggle_subscription_sections(not self.enable_subscription) clear_cache = True + if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold: + toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold) + clear_cache = True + if clear_cache: frappe.clear_cache() @@ -241,6 +247,10 @@ def toggle_subscription_sections(hide): create_property_setter_for_hiding_field(doctype, "subscription_section", hide) +def toggle_overdue_billing_threshold_field(hide): + create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide) + + def create_property_setter_for_hiding_field(doctype, field_name, hide): make_property_setter( doctype, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 051c7d87519..d206ec0a251 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -517,6 +517,7 @@ class SalesInvoice(SellingController): self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.check_credit_limit() + self.check_overdue_billing_threshold() if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() @@ -778,6 +779,11 @@ class SalesInvoice(SellingController): pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice) pos_invoice_doc.cancel() + def check_overdue_billing_threshold(self): + from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold + + check_overdue_billing_threshold(self.customer, self.company) + @frappe.whitelist() def set_missing_values(self, for_validate=False): pos = self.set_pos_fields(for_validate) diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 5556ab927b4..f5803a28276 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -475,10 +475,10 @@ "report_hide": 1 }, { - "description": "Transactions are blocked or warned when outstanding balance exceeds this amount.", + "description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.", "fieldname": "credit_limits", "fieldtype": "Table", - "label": "Credit Limit", + "label": "Credit & Overdue Limits", "options": "Customer Credit Limit", "show_description_on_click": 1 }, diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 971170d8335..126145792dd 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.query_builder import Field, functions -from frappe.utils import cint, cstr, flt, get_formatted_email, today +from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today from frappe.utils.user import get_users_with_role from erpnext.accounts.party import ( @@ -204,17 +204,21 @@ class Customer(TransactionBase): self.credit_limits = [] self.payment_terms = self.default_price_list = "" - tables = [["accounts", "account"], ["credit_limits", "credit_limit"]] + tables = [ + ["accounts", ["account"]], + ["credit_limits", ["credit_limit", "overdue_billing_threshold"]], + ] fields = ["payment_terms", "default_price_list"] for row in tables: - table, field = row[0], row[1] + table, table_fields = row[0], row[1] if not doc.get(table): continue for entry in doc.get(table): child = self.append(table) - child.update({"company": entry.company, field: entry.get(field)}) + child.update({"company": entry.company}) + child.update({field: entry.get(field) for field in table_fields}) for field in fields: if not doc.get(field): @@ -403,6 +407,9 @@ class Customer(TransactionBase): else: company_record.append(limit.company) + if not flt(limit.credit_limit): + continue + outstanding_amt = get_customer_outstanding( self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check ) @@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_ frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message) +def check_overdue_billing_threshold(customer: str, company: str) -> None: + if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"): + return + + threshold = get_overdue_billing_threshold(customer, company) + if not threshold: + return + + overdue_amount = get_customer_overdue_amount(customer, company) + if overdue_amount <= threshold: + return + + bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing") + if bypass_role and bypass_role in frappe.get_roles(): + return + + company_currency = frappe.get_cached_value("Company", company, "default_currency") + frappe.throw( + _("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format( + customer, + fmt_money(overdue_amount, currency=company_currency), + fmt_money(threshold, currency=company_currency), + ), + title=_("Overdue Limit Crossed"), + ) + + +def get_overdue_billing_threshold(customer: str, company: str) -> float: + """Overdue limit set on the customer, falling back to its customer group.""" + threshold = frappe.db.get_value( + "Customer Credit Limit", + {"parent": customer, "parenttype": "Customer", "company": company}, + "overdue_billing_threshold", + ) + + if not threshold: + customer_group = frappe.get_cached_value("Customer", customer, "customer_group") + threshold = frappe.db.get_value( + "Customer Credit Limit", + {"parent": customer_group, "parenttype": "Customer Group", "company": company}, + "overdue_billing_threshold", + ) + + return flt(threshold) + + +def get_customer_overdue_amount(customer: str, company: str) -> float: + """Amount the customer owes past its due date, in company currency. + + Follows the same rule as the Overdue invoice status, so a customer is only + blocked for what the invoice list already shows as overdue. + """ + invoices = get_outstanding_invoices_for_customer(customer, company) + if not invoices: + return 0.0 + + payable_amounts = get_past_due_payable_amounts([d.name for d in invoices]) + return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices)) + + +def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]: + from frappe.query_builder.functions import Sum + + gl_entry = frappe.qb.DocType("GL Entry") + sales_invoice = frappe.qb.DocType("Sales Invoice") + + # debit - credit is always booked in company currency, so this is comparable to the overdue limit + outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit) + + return ( + frappe.qb.from_(gl_entry) + .inner_join(sales_invoice) + .on(sales_invoice.name == gl_entry.against_voucher) + .select( + sales_invoice.name, + sales_invoice.due_date, + sales_invoice.base_grand_total, + outstanding.as_("outstanding"), + ) + .where(gl_entry.party_type == "Customer") + .where(gl_entry.party == customer) + .where(gl_entry.company == company) + .where(gl_entry.is_cancelled == 0) + .where(gl_entry.against_voucher_type == "Sales Invoice") + .groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total) + .having(outstanding > 0) + ).run(as_dict=True) + + +def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]: + from frappe.query_builder.functions import Sum + + payment_schedule = frappe.qb.DocType("Payment Schedule") + + rows = ( + frappe.qb.from_(payment_schedule) + .select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable")) + .where(payment_schedule.parenttype == "Sales Invoice") + .where(payment_schedule.parent.isin(invoices)) + .where(payment_schedule.due_date < getdate()) + .groupby(payment_schedule.parent) + ).run(as_dict=True) + + return {d.parent: flt(d.payable) for d in rows} + + +def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float: + outstanding = flt(invoice.outstanding) + + # No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are + # all still in the future. Both are answered by the invoice due date, which is the last term. + if payable_amount is None: + return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0 + + paid = flt(invoice.base_grand_total) - outstanding + return min(max(payable_amount - paid, 0.0), outstanding) + + def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None): # Outstanding based on GL Entries cond = "" diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 015c4467645..e7fcf2519fd 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -5,13 +5,15 @@ import json import frappe -from frappe.utils import flt, nowdate +from frappe.utils import add_days, flt, getdate, nowdate from erpnext.accounts.party import get_due_date from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.selling.doctype.customer.customer import ( get_credit_limit, get_customer_outstanding, + get_customer_overdue_amount, + get_overdue_billing_threshold, make_quotation, parse_full_name, ) @@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite): "company": "_Test Company", "account": "Creditors - _TC", } - test_credit_limits = {"company": "_Test Company", "credit_limit": 350000} + test_credit_limits = { + "company": "_Test Company", + "credit_limit": 350000, + "overdue_billing_threshold": 5000, + } doc.append("accounts", test_account_details) doc.append("credit_limits", test_credit_limits) doc.insert() @@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite): self.assertEqual(c_doc.credit_limits[0].company, "_Test Company") self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000) + self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000) c_doc.delete() doc.delete() @@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite): ) self.assertRaises(frappe.ValidationError, customer.save) + def test_get_customer_overdue_amount(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + baseline = get_customer_overdue_amount("_Test Customer", "_Test Company") + + # a past-due, unpaid invoice adds its outstanding to the overdue amount + create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30)) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500) + + # an invoice due today (not yet past due) does not + create_sales_invoice(qty=1, rate=700, posting_date=nowdate()) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500) + + def test_get_customer_overdue_amount_is_in_company_currency(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company") + + # 100 USD at a conversion rate of 50 must be counted as 5000 in company currency + create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + qty=1, + rate=100, + posting_date=add_days(nowdate(), -30), + ) + + self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000) + + def test_get_customer_overdue_amount_follows_payment_terms(self): + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + def make_invoice_with_terms(): + si = create_sales_invoice( + qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True + ) + si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50}) + si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50}) + si.insert() + si.submit() + return si + + baseline = get_customer_overdue_amount("_Test Customer", "_Test Company") + + # only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date + # is the last term (in 30 days), so this is only caught by reading the payment schedule. + si = make_invoice_with_terms() + self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30))) + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600) + + # paying off the past-due term clears the overdue amount + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") + pe.reference_no = "_Test Overdue Payment" + pe.reference_date = nowdate() + pe.paid_amount = pe.received_amount = 600 + pe.references[0].allocated_amount = 600 + pe.insert() + pe.submit() + self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline) + + def test_overdue_billing_threshold_on_submit(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30)) + overdue = get_customer_overdue_amount("_Test Customer", "_Test Company") + + settings = frappe.get_single("Accounts Settings") + original_enable = settings.enable_overdue_billing_threshold + original_bypass_role = settings.role_allowed_to_bypass_overdue_billing + try: + settings.enable_overdue_billing_threshold = 1 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100) + + # overdue is over the threshold and the user has no bypass role -> blocked + si = create_sales_invoice(do_not_submit=True) + self.assertRaises(frappe.ValidationError, si.submit) + + # a user holding the bypass role can still submit + settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager" + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) + + # threshold still crossed, but the feature is off -> never blocked + settings.enable_overdue_billing_threshold = 0 + settings.role_allowed_to_bypass_overdue_billing = None + settings.save() + si = create_sales_invoice(do_not_submit=True) + si.submit() + self.assertEqual(si.docstatus, 1) + finally: + settings.enable_overdue_billing_threshold = original_enable + settings.role_allowed_to_bypass_overdue_billing = original_bypass_role + settings.save() + + def test_overdue_billing_threshold_falls_back_to_customer_group(self): + customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group") + group = frappe.get_doc("Customer Group", customer_group) + group.credit_limits = [] + group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000}) + group.save() + + # the customer has no threshold of its own, so the group's applies + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000) + + # a threshold on the customer wins over the group + set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000) + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000) + + # a 0 on the customer inherits the group's limit + set_overdue_billing_threshold("_Test Customer", "_Test Company", 0) + self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000) + + def test_overdue_threshold_row_without_credit_limit(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + # outstanding must be > 0 so a 0 credit_limit would previously trip the check + create_sales_invoice(qty=1, rate=500) + + customer = frappe.get_doc("Customer", "_Test Customer") + customer.credit_limits = [] + customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000}) + customer.save() + + self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000) + self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0) + def test_customer_payment_terms(self): frappe.db.set_value( "Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3" @@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit): customer.credit_limits[-1].db_insert() +def set_overdue_billing_threshold(customer, company, threshold): + customer = frappe.get_doc("Customer", customer) + for d in customer.credit_limits: + if d.company == company: + d.overdue_billing_threshold = threshold + d.db_update() + return + + customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold}) + customer.credit_limits[-1].db_insert() + + def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None): if not customer_name: customer_name = represents_company diff --git a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json index f738b3629fa..e208148ae08 100644 --- a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json +++ b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json @@ -8,6 +8,7 @@ "company", "column_break_2", "credit_limit", + "overdue_billing_threshold", "bypass_credit_limit_check" ], "fields": [ @@ -18,6 +19,15 @@ "in_list_view": 1, "label": "Credit Limit" }, + { + "columns": 3, + "description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.", + "fieldname": "overdue_billing_threshold", + "fieldtype": "Currency", + "hidden": 1, + "in_list_view": 1, + "label": "Overdue Limit" + }, { "fieldname": "column_break_2", "fieldtype": "Column Break" diff --git a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py index fcc6c6e6db6..e0e21d71c91 100644 --- a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py +++ b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.py @@ -18,6 +18,7 @@ class CustomerCreditLimit(Document): bypass_credit_limit_check: DF.Check company: DF.Link | None credit_limit: DF.Currency + overdue_billing_threshold: DF.Currency parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/setup/doctype/customer_group/customer_group.json b/erpnext/setup/doctype/customer_group/customer_group.json index 40317c2f8f7..5461155e409 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.json +++ b/erpnext/setup/doctype/customer_group/customer_group.json @@ -132,7 +132,7 @@ { "fieldname": "credit_limits", "fieldtype": "Table", - "label": "Credit Limit", + "label": "Credit & Overdue Limits", "options": "Customer Credit Limit" } ],