From 0e64acb0fa04042268805e11fa4f7b4a082708aa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:23:49 +0000 Subject: [PATCH] fix: prevent leakage of party-derived fields in cross doctype transactions (backport #55336) (#55578) Co-authored-by: Nabin Hait Co-authored-by: Lakshit Jain fix: prevent leakage of party-derived fields in cross doctype transactions (#55336) --- .../doctype/sales_invoice/sales_invoice.py | 9 ++++- .../sales_invoice/test_sales_invoice.py | 28 ++++++++++++++ erpnext/accounts/party.py | 19 ++++++++++ .../doctype/sales_order/sales_order.py | 35 +++++------------ .../doctype/sales_order/test_sales_order.py | 35 ++++++++++++++--- .../doctype/delivery_note/delivery_note.py | 5 +-- .../purchase_receipt/test_purchase_receipt.py | 38 +++++++++++++++++++ 7 files changed, 133 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index df76ef6831e..5d6676aa537 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -23,7 +23,12 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category get_party_tax_withholding_details, ) from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center -from erpnext.accounts.party import get_due_date, get_party_account, get_party_details +from erpnext.accounts.party import ( + CROSS_PARTY_FIELD_NO_MAP, + get_due_date, + get_party_account, + get_party_details, +) from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, get_account_currency, @@ -2559,7 +2564,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "doctype": target_doctype, "postprocess": update_details, "set_target_warehouse": "set_from_warehouse", - "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"], }, doctype + " Item": item_field_map, }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index ef7c25c86a9..e210903c450 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2751,6 +2751,34 @@ class TestSalesInvoice(FrappeTestCase): ): make_inter_company_transaction("Sales Invoice", si.name) + def test_inter_company_transaction_does_not_inherit_party_fields(self): + """ + Party-derived fields on SI (from Customer) must not leak into the mapped PI. + """ + si = create_sales_invoice( + company="Wind Power LLC", + customer="_Test Internal Customer", + debit_to="Debtors - WP", + warehouse="Stores - WP", + income_account="Sales - WP", + expense_account="Cost of Goods Sold - WP", + cost_center="Main - WP", + currency="USD", + do_not_save=1, + ) + si.selling_price_list = "_Test Price List Rest of the World" + si.tax_category = "_Test Tax Category 1" + si.language = "ar" + si.payment_terms_template = "_Test Payment Term Template" + si.submit() + + pi = make_inter_company_transaction("Sales Invoice", si.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier") + self.assertEqual(pi.tax_category or None, supplier.tax_category or None) + self.assertEqual(pi.language or None, supplier.language or None) + self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None) + def test_inter_company_transaction_without_default_warehouse(self): "Check mapping (expense account) of inter company SI to PI in absence of default warehouse." # setup diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index a5d940d7b7f..5c0860b4fd3 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = { } TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES +# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the +# source and target documents belong to different parties (e.g. Sales Order → +# Purchase Order or inter-company Sales Invoice → Purchase Invoice). +CROSS_PARTY_FIELD_NO_MAP = [ + "tax_category", + "tax_id", + "tax_withholding_category", + "taxes_and_charges", + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "shipping_address", + "dispatch_address", + "payment_terms_template", + "language", +] + class DuplicatePartyAccountError(frappe.ValidationError): pass diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 856a75606bc..1dd62a65239 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -10,6 +10,7 @@ import frappe.utils from frappe import _, qb from frappe.contacts.doctype.address.address import get_company_address from frappe.desk.notifications import clear_doctype_notifications +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import Sum @@ -20,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.party import get_party_account +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, @@ -1332,7 +1333,9 @@ def get_events(start, end, filters=None): @frappe.whitelist() -def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None): +def make_purchase_order_for_default_supplier( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): """Creates Purchase Order for each Supplier. Returns a list of doc objects.""" from erpnext.setup.utils import get_exchange_rate @@ -1361,7 +1364,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") @@ -1418,16 +1420,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { @@ -1492,7 +1485,9 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t @frappe.whitelist() -def make_purchase_order(source_name, selected_items=None, target_doc=None): +def make_purchase_order( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): if not selected_items: return @@ -1520,7 +1515,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] if is_drop_ship_order(target): @@ -1559,16 +1553,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 18d416ad7df..6f8befeb876 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -24,6 +24,8 @@ from erpnext.selling.doctype.sales_order.sales_order import ( create_pick_list, make_delivery_note, make_material_request, + make_purchase_order, + make_purchase_order_for_default_supplier, make_raw_material_request, make_sales_invoice, make_work_orders, @@ -1364,8 +1366,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): Tests if the the Product Bundles in the Items table of Sales Orders are replaced with their child items(from the Packed Items table) on creating a Purchase Order from it. """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -1394,8 +1394,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -2419,8 +2417,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") def test_item_tax_transfer_from_sales_to_purchase(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - item_tax = frappe.new_doc("Item Tax Template") item_tax.title = "Test Item Tax Template" item_tax.company = "_Test Company" @@ -2521,6 +2517,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertFalse(so.per_billed) self.assertEqual(so.status, "To Deliver and Bill") + def test_make_purchase_order_does_not_inherit_party_fields(self): + """ + Customer-derived fields must not leak from a drop-ship SO into the PO. + """ + so_items = [ + { + "item_code": "_Test Item", + "warehouse": "", + "qty": 1, + "rate": 100, + "delivered_by_supplier": 1, + "supplier": "_Test Supplier", + } + ] + so = make_sales_order(item_list=so_items, do_not_submit=True) + so.tax_category = "_Test Tax Category 1" + so.language = "ar" + so.payment_terms_template = "_Test Payment Term Template" + so.submit() + + po = make_purchase_order_for_default_supplier(so.name, selected_items=so_items)[0] + + supplier = frappe.get_doc("Supplier", "_Test Supplier") + self.assertEqual(po.tax_category or None, supplier.tax_category or None) + self.assertEqual(po.language or None, supplier.language or None) + self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None) + def test_pending_quantity_after_update_item_during_invoice_creation(self): so = make_sales_order(qty=30, rate=100) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f1fc54c751d..0ad8bc781a5 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -15,7 +15,7 @@ from frappe.query_builder import DocType from frappe.query_builder.functions import Abs, Sum from frappe.utils import cint, flt -from erpnext.accounts.party import get_due_date +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController from erpnext.stock.stock_ledger import validate_reserved_stock @@ -1405,8 +1405,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): doctype: { "doctype": target_doctype, "postprocess": update_details, - "field_no_map": ["taxes_and_charges", "set_warehouse"], - "field_map": {"shipping_address_name": "shipping_address"}, + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"], }, doctype + " Item": { "doctype": target_doctype + " Item", diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index ce23acf85e2..3799e773a7d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1050,6 +1050,44 @@ class TestPurchaseReceipt(FrappeTestCase): pr.cancel() + def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self): + """ + Party-derived fields on DN (from Customer) must not leak into the mapped PR. + """ + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + stock = make_purchase_receipt(warehouse="Stores - TCP1", company=company) + + dn = create_delivery_note( + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + do_not_submit=True, + ) + # Stamp customer-side party fields onto the DN + dn.tax_category = "_Test Tax Category 2" + dn.language = "ar" + dn.submit() + + pr = make_inter_company_purchase_receipt(dn.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2") + self.assertEqual(pr.tax_category or None, supplier.tax_category or None) + self.assertEqual(pr.language or None, supplier.language or None) + dn.cancel() + stock.cancel() + def test_lcv_for_internal_transfer(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note