From 7b517a4e647aeeaef0ed52a2945f2a92f30aceff Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 16 Jul 2026 15:34:45 +0530 Subject: [PATCH] feat: book Expenses Added To Stock GL entries for stock vouchers (configurable) (#57190) * feat: book Expenses Added To Stock GL entries for Stock Entry, Stock Reconciliation and LCV Co-Authored-By: Claude Fable 5 * feat: make stock expense GL booking configurable via Accounts Settings Co-Authored-By: Claude Fable 5 * fix: skip stock expense booking for unconfigured companies, check flag once per compose --------- Co-authored-by: Claude Fable 5 --- .../accounts_settings/accounts_settings.json | 16 +- .../accounts_settings/accounts_settings.py | 1 + erpnext/controllers/buying_controller.py | 46 +++-- erpnext/patches.txt | 1 + .../enable_book_stock_expense_gl_entries.py | 10 ++ erpnext/setup/doctype/company/company.js | 2 + erpnext/setup/doctype/company/company.json | 27 ++- erpnext/setup/doctype/company/company.py | 2 + .../setup/doctype/item_group/item_group.js | 21 +++ .../setup/doctype/item_group/item_group.py | 2 + erpnext/stock/doctype/item/item.js | 10 +- .../doctype/item_default/item_default.json | 34 +++- .../doctype/item_default/item_default.py | 2 + .../purchase_receipt/services/gl_composer.py | 9 + .../purchase_receipt/test_purchase_receipt.py | 1 + .../stock_entry/services/gl_composer.py | 1 + .../services/gl_composer.py | 1 + .../stock/services/base_stock_gl_composer.py | 108 ++++++++++- .../tests/test_expenses_added_to_stock.py | 170 ++++++++++++++++++ 19 files changed, 440 insertions(+), 24 deletions(-) create mode 100644 erpnext/patches/v16_0/enable_book_stock_expense_gl_entries.py create mode 100644 erpnext/stock/tests/test_expenses_added_to_stock.py diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index da92cdd5b0a..7910dc5a30a 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -22,6 +22,8 @@ "allow_multi_currency_invoices_against_single_party_account", "confirm_before_resetting_posting_date", "preview_mode", + "stock_expense_section", + "book_stock_expense_gl_entries", "analytics_section", "enable_discounts_and_margin", "enable_accounting_dimensions", @@ -757,6 +759,18 @@ "description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.", "fieldname": "column_break_mfor", "fieldtype": "Column Break" + }, + { + "fieldname": "stock_expense_section", + "fieldtype": "Section Break", + "label": "Stock Expense Accounting" + }, + { + "default": "0", + "description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher", + "fieldname": "book_stock_expense_gl_entries", + "fieldtype": "Check", + "label": "Book Stock Expense GL Entries" } ], "grid_page_length": 50, @@ -765,7 +779,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-24 12:59:41.868865", + "modified": "2026-07-15 17:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index c56d39ad8d9..59eb671b33b 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -62,6 +62,7 @@ class AccountsSettings(Document): book_asset_depreciation_entry_automatically: DF.Check book_deferred_entries_based_on: DF.Literal["Days", "Months"] book_deferred_entries_via_journal_entry: DF.Check + book_stock_expense_gl_entries: DF.Check book_tax_discount_loss: DF.Check calculate_depr_using_total_days: DF.Check check_supplier_invoice_uniqueness: DF.Check diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 1f947bf1fb6..842114f6512 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -330,30 +330,38 @@ class BuyingController(SubcontractingController): address_display_field, render_address(self.get(address_field), check_permissions=False) ) + def get_validated_purchase_expense_details(self, item_code): + fields = ("purchase_expense_account", "purchase_expense_contra_account") + details = get_purchase_expense_account(item_code, self.company) + + for field in fields: + if not details.get(field): + details[field] = frappe.get_cached_value("Company", self.company, field) + + if not any(details.get(field) for field in fields): + return None + + for field in fields: + if not details.get(field): + frappe.throw( + _("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format( + frappe.bold(_(frappe.unscrub(field))), self.company, item_code + ) + ) + + return details + def set_gl_entry_for_purchase_expense(self, gl_entries): + if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")): + return + if self.doctype == "Purchase Invoice" and not self.update_stock: return for row in self.items: - details = get_purchase_expense_account(row.item_code, self.company) - - if not details.purchase_expense_account: - details.purchase_expense_account = frappe.get_cached_value( - "Company", self.company, "purchase_expense_account" - ) - - if not details.purchase_expense_account: - return - - if not details.purchase_expense_contra_account: - details.purchase_expense_contra_account = frappe.get_cached_value( - "Company", self.company, "purchase_expense_contra_account" - ) - - if not details.purchase_expense_contra_account: - frappe.throw( - _("Please set Purchase Expense Contra Account in Company {0}").format(self.company) - ) + details = self.get_validated_purchase_expense_details(row.item_code) + if not details: + continue amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount")) self.add_gl_entry( diff --git a/erpnext/patches.txt b/erpnext/patches.txt index e748cab0008..ef59dc40acf 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -499,3 +499,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.access_control_for_project_users +erpnext.patches.v16_0.enable_book_stock_expense_gl_entries diff --git a/erpnext/patches/v16_0/enable_book_stock_expense_gl_entries.py b/erpnext/patches/v16_0/enable_book_stock_expense_gl_entries.py new file mode 100644 index 00000000000..c21add4e073 --- /dev/null +++ b/erpnext/patches/v16_0/enable_book_stock_expense_gl_entries.py @@ -0,0 +1,10 @@ +import frappe + + +def execute(): + has_expense_accounts = frappe.db.exists( + "Company", {"purchase_expense_account": ("is", "set")} + ) or frappe.db.exists("Item Default", {"purchase_expense_account": ("is", "set")}) + + if has_expense_accounts: + frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 84b79c95074..b1d75796c8f 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -323,6 +323,8 @@ erpnext.company.setup_queries = function (frm) { ["default_advance_received_account", { root_type: "Liability", account_type: "Receivable" }], ["default_advance_paid_account", { root_type: "Asset", account_type: "Payable" }], ["service_expense_account", { root_type: "Expense" }], + ["expenses_added_to_stock_account", { root_type: "Expense" }], + ["expenses_added_to_stock_contra_account", { root_type: "Expense" }], ], function (i, v) { erpnext.company.set_custom_query(frm, v); diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 0036ea249ba..9d0fcef0c4e 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -137,6 +137,10 @@ "disable_sdbnb_in_sr", "default_provisional_account", "default_in_transit_warehouse", + "stock_expense_section", + "expenses_added_to_stock_account", + "column_break_gthb", + "expenses_added_to_stock_contra_account", "manufacturing_section", "default_operating_cost_account", "column_break_9prc", @@ -962,6 +966,18 @@ "label": "Service Expense Account", "options": "Account" }, + { + "fieldname": "expenses_added_to_stock_account", + "fieldtype": "Link", + "label": "Expenses Added To Stock Account", + "options": "Account" + }, + { + "fieldname": "expenses_added_to_stock_contra_account", + "fieldtype": "Link", + "label": "Expenses Added To Stock Contra Account", + "options": "Account" + }, { "default": "0", "description": "If enabled, the system will use the inventory account set in the Item Master or Item Group or Brand. Otherwise, it will use the inventory account set in the Warehouse.", @@ -1071,6 +1087,15 @@ "fieldname": "enable_stock_delivered_but_not_billed", "fieldtype": "Check", "label": "Enable Stock Delivered But Not Billed" + }, + { + "fieldname": "stock_expense_section", + "fieldtype": "Section Break", + "label": "Stock Expense" + }, + { + "fieldname": "column_break_gthb", + "fieldtype": "Column Break" } ], "grid_page_length": 50, @@ -1079,7 +1104,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2026-07-02 07:21:21.794533", + "modified": "2026-07-15 15:38:29.214020", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 420804a552f..99179fc27b3 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -104,6 +104,8 @@ class Company(NestedSet): exception_budget_approver_role: DF.Link | None exchange_gain_loss_account: DF.Link | None existing_company: DF.Link | None + expenses_added_to_stock_account: DF.Link | None + expenses_added_to_stock_contra_account: DF.Link | None fax: DF.Data | None is_group: DF.Check lft: DF.Int diff --git a/erpnext/setup/doctype/item_group/item_group.js b/erpnext/setup/doctype/item_group/item_group.js index fe9db5299f5..8c14bb9e47c 100644 --- a/erpnext/setup/doctype/item_group/item_group.js +++ b/erpnext/setup/doctype/item_group/item_group.js @@ -75,6 +75,23 @@ frappe.ui.form.on("Item Group", { }, }; }; + + ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"].forEach((field) => { + frm.fields_dict["item_group_defaults"].grid.get_field(field).get_query = function ( + doc, + cdt, + cdn + ) { + const row = locals[cdt][cdn]; + return { + filters: { + root_type: "Expense", + company: row.company, + is_group: 0, + }, + }; + }; + }); }, refresh: function (frm) { @@ -174,6 +191,8 @@ const COMPANY_DEFAULTS_TO_VF = { default_discount_account: "vf_default_discount_account", default_supplier: "vf_default_supplier", purchase_expense_contra_account: "vf_purchase_expense_contra_account", + expenses_added_to_stock_account: "vf_expenses_added_to_stock_account", + expenses_added_to_stock_contra_account: "vf_expenses_added_to_stock_contra_account", }; const FIELD_DEFAULT_SOURCE = { @@ -192,6 +211,8 @@ const FIELD_DEFAULT_SOURCE = { default_discount_account: "Company", default_supplier: null, purchase_expense_contra_account: "Company", + expenses_added_to_stock_account: "Company", + expenses_added_to_stock_contra_account: "Company", }; function populate_item_group_company_defaults(frm, cdt, cdn, row) { diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index b4733fb36cf..920e98cf528 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -126,6 +126,8 @@ def get_company_resolved_defaults(company: str) -> dict: "deferred_revenue_account": company_doc.get("default_deferred_revenue_account"), "default_discount_account": company_doc.get("default_discount_account"), "purchase_expense_contra_account": company_doc.get("purchase_expense_contra_account"), + "expenses_added_to_stock_account": company_doc.get("expenses_added_to_stock_account"), + "expenses_added_to_stock_contra_account": company_doc.get("expenses_added_to_stock_contra_account"), "default_price_list": "", "default_supplier": "", } diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index eb8034b57c4..d59a208dad6 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -17,6 +17,8 @@ const virtual_field_map = { default_provisional_account: "vf_default_provisional_account", purchase_expense_account: "vf_purchase_expense_account", purchase_expense_contra_account: "vf_purchase_expense_contra_account", + expenses_added_to_stock_account: "vf_expenses_added_to_stock_account", + expenses_added_to_stock_contra_account: "vf_expenses_added_to_stock_contra_account", selling_cost_center: "vf_selling_cost_center", income_account: "vf_income_account", default_cogs_account: "vf_default_cogs_account", @@ -787,7 +789,13 @@ $.extend(erpnext.item, { }; }); - let fields = ["purchase_expense_account", "purchase_expense_contra_account", "default_cogs_account"]; + let fields = [ + "purchase_expense_account", + "purchase_expense_contra_account", + "default_cogs_account", + "expenses_added_to_stock_account", + "expenses_added_to_stock_contra_account", + ]; fields.forEach((field) => { frm.set_query(field, "item_defaults", (doc, cdt, cdn) => { diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 73a25bd4cea..d1cc7e93253 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -27,6 +27,8 @@ "vf_default_provisional_account", "vf_purchase_expense_account", "vf_purchase_expense_contra_account", + "vf_expenses_added_to_stock_account", + "vf_expenses_added_to_stock_contra_account", "column_break_ghzl", "buying_cost_center", "default_supplier", @@ -34,6 +36,8 @@ "default_provisional_account", "purchase_expense_account", "purchase_expense_contra_account", + "expenses_added_to_stock_account", + "expenses_added_to_stock_contra_account", "purchase_price_variance_account", "manufacturing_variance_account", "selling_defaults", @@ -191,6 +195,22 @@ "options": "Account", "show_description_on_click": 1 }, + { + "description": "Account to track value added to stock via Stock Entry, Stock Reconciliation or Landed Cost Voucher", + "fieldname": "expenses_added_to_stock_account", + "fieldtype": "Link", + "label": "Expenses Added To Stock Account", + "options": "Account", + "show_description_on_click": 1 + }, + { + "description": "Used to balance the books when recording expenses added to stock", + "fieldname": "expenses_added_to_stock_contra_account", + "fieldtype": "Link", + "label": "Expenses Added To Stock Contra Account", + "options": "Account", + "show_description_on_click": 1 + }, { "description": "For Standard Cost items: the purchase price vs standard rate difference is booked here. Falls back to the Company's Default Purchase Price Variance Account.", "fieldname": "purchase_price_variance_account", @@ -247,6 +267,18 @@ "is_virtual": 1, "label": "Purchase Expense Contra Account" }, + { + "fieldname": "vf_expenses_added_to_stock_account", + "fieldtype": "Read Only", + "is_virtual": 1, + "label": "Expenses Added To Stock Account" + }, + { + "fieldname": "vf_expenses_added_to_stock_contra_account", + "fieldtype": "Read Only", + "is_virtual": 1, + "label": "Expenses Added To Stock Contra Account" + }, { "fieldname": "selling_defaults", "fieldtype": "Section Break", @@ -374,7 +406,7 @@ ], "istable": 1, "links": [], - "modified": "2026-07-01 11:48:07.853494", + "modified": "2026-07-15 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Item Default", diff --git a/erpnext/stock/doctype/item_default/item_default.py b/erpnext/stock/doctype/item_default/item_default.py index 0a92b9e8b61..d8e751c53a4 100644 --- a/erpnext/stock/doctype/item_default/item_default.py +++ b/erpnext/stock/doctype/item_default/item_default.py @@ -26,6 +26,8 @@ class ItemDefault(Document): deferred_expense_account: DF.Link | None deferred_revenue_account: DF.Link | None expense_account: DF.Link | None + expenses_added_to_stock_account: DF.Link | None + expenses_added_to_stock_contra_account: DF.Link | None income_account: DF.Link | None inventory_account_currency: DF.Link | None manufacturing_variance_account: DF.Link | None diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py index 9d68546445a..cab4337f4bf 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py +++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py @@ -191,6 +191,14 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): item=item, ) + def make_expenses_added_to_stock_entries(item): + if not self.book_stock_expense_enabled(): + return + + amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount")) + if amount and not item.is_fixed_asset: + self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item) + def make_amount_difference_entry(item): if item.amount_difference_with_purchase_invoice and stock_asset_rbnb: account_currency = get_account_currency(stock_asset_rbnb) @@ -321,6 +329,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) outgoing_amount = make_stock_received_but_not_billed_entry(d) make_landed_cost_gl_entries(d) + make_expenses_added_to_stock_entries(d) make_amount_difference_entry(d) make_sub_contracting_gl_entries(d) make_divisional_loss_gl_entry(d, outgoing_amount) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index f5a090262fb..88ecca87492 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5054,6 +5054,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(srbnb_cost, 1000) def test_purchase_expense_account(self): + frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1) item = "Test Item with Purchase Expense Account" make_item(item, {"is_stock_item": 1}) company = "_Test Company with perpetual inventory" diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index ab254e33699..6957a99ca99 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -20,6 +20,7 @@ class StockEntryGLComposer(BaseStockGLComposer): """ enforce_pl_expense_account = False + book_expenses_added_to_stock = True def compose(self, inventory_account_map: dict | None = None) -> list: doc = self.doc diff --git a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py index 59335ac8674..66e2c334c17 100644 --- a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py @@ -18,6 +18,7 @@ class StockReconciliationGLComposer(BaseStockGLComposer): """ enforce_pl_expense_account = False + book_expenses_added_to_stock = True def compose(self, inventory_account_map: dict | None = None) -> list: doc = self.doc diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py index bfe042e501a..15b81cab5f5 100644 --- a/erpnext/stock/services/base_stock_gl_composer.py +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.utils import flt +from frappe.utils import cint, flt from erpnext.accounts.general_ledger import process_gl_map from erpnext.accounts.services.base_gl_composer import BaseGLComposer @@ -22,6 +22,8 @@ class BaseStockGLComposer(BaseGLComposer): #: account (stock transfers, deliveries, reconciliations) set this to False. enforce_pl_expense_account = True + book_expenses_added_to_stock = False + def compose( self, inventory_account_map: dict | None = None, @@ -154,6 +156,9 @@ class BaseStockGLComposer(BaseGLComposer): ).format(wh, doc.company) ) + if self.book_expenses_added_to_stock: + self.append_expenses_added_to_stock_entries(gl_list, voucher_details, sle_map) + return process_gl_map( gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation ) @@ -164,6 +169,81 @@ class BaseStockGLComposer(BaseGLComposer): return frappe.flags.debit_field_precision + def book_stock_expense_enabled(self): + if not hasattr(self, "_book_stock_expense_enabled"): + self._book_stock_expense_enabled = cint( + frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries") + ) + + return self._book_stock_expense_enabled + + def append_expenses_added_to_stock_entries(self, gl_list, voucher_details, sle_map): + if not self.book_stock_expense_enabled(): + return + + precision = self.get_debit_field_precision() + + for item_row in voucher_details: + sle_list = sle_map.get(item_row.name) + if not sle_list: + continue + + amount = flt(sum(flt(sle.stock_value_difference) for sle in sle_list), precision) + if not amount: + continue + + item_code = item_row.get("item_code") or sle_list[0].item_code + self.append_expenses_added_to_stock_pair(gl_list, item_code, amount, item_row) + + def append_expenses_added_to_stock_pair(self, gl_list, item_code, amount, item_row): + doc = self.doc + fields = ("expenses_added_to_stock_account", "expenses_added_to_stock_contra_account") + details = get_expenses_added_to_stock_accounts(item_code, doc.company) + + if not any(details.get(field) for field in fields): + return + + for field in fields: + if not details.get(field): + frappe.throw( + _("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format( + frappe.bold(_(frappe.unscrub(field))), doc.company, item_code + ) + ) + + cost_center = item_row.get("cost_center") or frappe.get_cached_value( + "Company", doc.company, "cost_center" + ) + remarks = _("Expenses Added To Stock for Item {0}").format(item_code) + common_args = { + "cost_center": cost_center, + "project": item_row.get("project") or doc.get("project"), + "remarks": remarks, + } + + gl_list.append( + self.get_gl_dict( + { + "account": details.expenses_added_to_stock_account, + "against": details.expenses_added_to_stock_contra_account, + "debit": amount, + **common_args, + }, + item=item_row, + ) + ) + gl_list.append( + self.get_gl_dict( + { + "account": details.expenses_added_to_stock_contra_account, + "against": details.expenses_added_to_stock_account, + "debit": -1 * amount, + **common_args, + }, + item=item_row, + ) + ) + def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): details = self.doc.get("items") @@ -203,3 +283,29 @@ class BaseStockGLComposer(BaseGLComposer): _(self.doc.doctype), self.doc.name, item.get("item_code") ) ) + + +@frappe.request_cache +def get_expenses_added_to_stock_accounts(item_code, company): + from erpnext.stock.doctype.item.item import get_item_defaults + + fields = ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"] + defaults = get_item_defaults(item_code, company) + + details = frappe._dict({field: defaults.get(field) for field in fields}) + + if not details.expenses_added_to_stock_account: + details = frappe.db.get_value( + "Item Default", {"parent": defaults.item_group, "company": company}, fields, as_dict=1 + ) or frappe._dict({}) + + if not details.expenses_added_to_stock_account and defaults.get("brand"): + details = frappe.db.get_value( + "Item Default", {"parent": defaults.brand, "company": company}, fields, as_dict=1 + ) or frappe._dict({}) + + for field in fields: + if not details.get(field): + details[field] = frappe.get_cached_value("Company", company, field) + + return details diff --git a/erpnext/stock/tests/test_expenses_added_to_stock.py b/erpnext/stock/tests/test_expenses_added_to_stock.py new file mode 100644 index 00000000000..2fe9e0ba7e4 --- /dev/null +++ b/erpnext/stock/tests/test_expenses_added_to_stock.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.accounts.doctype.account.test_account import create_account +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestSuite + +COMPANY = "_Test Company with perpetual inventory" +WAREHOUSE = "Stores - TCP1" + + +class TestExpensesAddedToStock(ERPNextTestSuite): + def setUp(self): + self.eats_account = create_account( + account_name="Expenses Added To Stock", + parent_account="Expenses - TCP1", + company=COMPANY, + ) + self.eats_contra_account = create_account( + account_name="Expenses Added To Stock Contra", + parent_account="Expenses - TCP1", + company=COMPANY, + ) + self.purchase_expense_account = create_account( + account_name="Test Purchase Expense EATS", + parent_account="Expenses - TCP1", + company=COMPANY, + ) + self.purchase_expense_contra_account = create_account( + account_name="Test Purchase Expense Contra EATS", + parent_account="Expenses - TCP1", + company=COMPANY, + ) + frappe.db.set_value( + "Company", + COMPANY, + { + "expenses_added_to_stock_account": self.eats_account, + "expenses_added_to_stock_contra_account": self.eats_contra_account, + "purchase_expense_account": self.purchase_expense_account, + "purchase_expense_contra_account": self.purchase_expense_contra_account, + }, + ) + frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1) + self.item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name + + def get_gl_balances(self, voucher_type, voucher_no): + entries = frappe.get_all( + "GL Entry", + filters={ + "voucher_type": voucher_type, + "voucher_no": voucher_no, + "is_cancelled": 0, + "account": ("in", [self.eats_account, self.eats_contra_account]), + }, + fields=["account", "debit", "credit"], + ) + + balances = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0}) + debits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0}) + credits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0}) + for entry in entries: + balances[entry.account] += entry.debit - entry.credit + debits[entry.account] += entry.debit + credits[entry.account] += entry.credit + + return balances, debits, credits + + def test_material_receipt_books_expenses_added_to_stock(self): + se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + + _balances, debits, credits = self.get_gl_balances("Stock Entry", se.name) + self.assertEqual(debits[self.eats_account], 1000) + self.assertEqual(credits[self.eats_contra_account], 1000) + + def test_material_issue_books_reverse_pair(self): + make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + se = make_stock_entry(item_code=self.item, from_warehouse=WAREHOUSE, qty=5, company=COMPANY) + + _balances, debits, credits = self.get_gl_balances("Stock Entry", se.name) + self.assertEqual(credits[self.eats_account], 500) + self.assertEqual(debits[self.eats_contra_account], 500) + + def test_material_transfer_books_nothing(self): + make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + se = make_stock_entry( + item_code=self.item, + from_warehouse=WAREHOUSE, + to_warehouse="Finished Goods - TCP1", + qty=5, + company=COMPANY, + ) + + _balances, debits, credits = self.get_gl_balances("Stock Entry", se.name) + self.assertEqual(debits[self.eats_account], 0) + self.assertEqual(credits[self.eats_account], 0) + + def test_stock_reconciliation_books_pair(self): + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + sr = create_stock_reconciliation( + item_code=self.item, warehouse=WAREHOUSE, qty=15, rate=100, company=COMPANY + ) + + _balances, debits, credits = self.get_gl_balances("Stock Reconciliation", sr.name) + self.assertEqual(debits[self.eats_account], 500) + self.assertEqual(credits[self.eats_contra_account], 500) + + def test_landed_cost_voucher_books_pair(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + create_landed_cost_voucher, + ) + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + pr = make_purchase_receipt( + company=COMPANY, warehouse=WAREHOUSE, item_code=self.item, qty=10, rate=100 + ) + + _balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name) + self.assertEqual(debits[self.eats_account], 0) + + create_landed_cost_voucher("Purchase Receipt", pr.name, COMPANY, charges=200) + + _balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name) + self.assertEqual(debits[self.eats_account], 200) + self.assertEqual(credits[self.eats_contra_account], 200) + + def test_no_entries_when_feature_disabled(self): + frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 0) + + se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + + _balances, debits, credits = self.get_gl_balances("Stock Entry", se.name) + self.assertEqual(debits[self.eats_account], 0) + self.assertEqual(credits[self.eats_contra_account], 0) + + def test_unconfigured_company_skips_booking(self): + frappe.db.set_value( + "Company", + COMPANY, + { + "expenses_added_to_stock_account": None, + "expenses_added_to_stock_contra_account": None, + }, + ) + + se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY) + + _balances, debits, credits = self.get_gl_balances("Stock Entry", se.name) + self.assertEqual(debits[self.eats_account], 0) + self.assertEqual(credits[self.eats_contra_account], 0) + + def test_missing_contra_account_raises_when_feature_enabled(self): + frappe.db.set_value("Company", COMPANY, "expenses_added_to_stock_contra_account", None) + + self.assertRaises( + frappe.ValidationError, + make_stock_entry, + item_code=self.item, + to_warehouse=WAREHOUSE, + qty=10, + rate=100, + company=COMPANY, + )