diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 06d5b341aef..b6433e5baf7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1433,6 +1433,10 @@ class PurchaseInvoice(BuyingController): # tax table gl entries valuation_tax = {} + # Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by + # tax row name - a non-stock item's share of a spread-across-all-items charge is excluded. + capitalized_valuation_tax = self.get_capitalized_valuation_tax() + for tax in self.get("taxes"): amount, base_amount = self.get_tax_amounts(tax, None) if tax.category in ("Total", "Valuation and Total") and flt(base_amount): @@ -1469,8 +1473,7 @@ class PurchaseInvoice(BuyingController): tax.idx, _(tax.category) ) ) - valuation_tax.setdefault(tax.name, 0) - valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount) + valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0) if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax: # credit valuation tax amount in "Expenses Included In Valuation" diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json index 44c3d2719e6..8a4d95c6421 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json @@ -11,6 +11,7 @@ "add_deduct_tax", "charge_type", "row_id", + "allocate_full_amount_to_stock_items", "included_in_print_rate", "included_in_paid_amount", "col_break1", @@ -78,6 +79,14 @@ "oldfieldname": "row_id", "oldfieldtype": "Data" }, + { + "default": "1", + "depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)", + "description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.", + "fieldname": "allocate_full_amount_to_stock_items", + "fieldtype": "Check", + "label": "Allocate Full Amount to Stock Items" + }, { "default": "0", "description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount", diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 98799296854..cad62820d03 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -414,39 +414,29 @@ class BuyingController(SubcontractingController): stock_and_asset_items = [] stock_and_asset_items = self.get_stock_items() + self.get_asset_items() - stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0 - last_item_idx = 1 - for d in self.get("items"): - if d.item_code: - stock_and_asset_items_qty += flt(d.qty) - stock_and_asset_items_amount += flt(d.base_net_amount) + ( + tax_accounts, + total_valuation_amount, + all_item_charges, + stock_item_charges, + ) = self.get_tax_details() - last_item_idx = d.idx + # Pre-compute each item's share of the "Actual" valuation charges (keyed by row idx). + actual_charge_per_item = self.distribute_actual_tax_amount( + stock_and_asset_items, all_item_charges, stock_item_charges + ) - tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details() - remaining_amount = total_actual_tax_amount + last_item_idx = max((d.idx for d in self.get("items")), default=1) for i, item in enumerate(self.get("items")): if item.item_code and (item.qty or item.get("rejected_qty")): - item_tax_amount, actual_tax_amount = 0.0, 0.0 if i == (last_item_idx - 1): + # dump any rounding remainder of the On Net Total valuation on the last item item_tax_amount = total_valuation_amount - actual_tax_amount = remaining_amount else: - # calculate item tax amount item_tax_amount = self.get_item_tax_amount(item, tax_accounts) total_valuation_amount -= item_tax_amount - if total_actual_tax_amount: - actual_tax_amount = self.get_item_actual_tax_amount( - item, - total_actual_tax_amount, - stock_and_asset_items_amount, - stock_and_asset_items_qty, - ) - - remaining_amount -= actual_tax_amount - # This code is required here to calculate the correct valuation for stock items if item.item_code not in stock_and_asset_items: item.valuation_rate = 0.0 @@ -454,7 +444,8 @@ class BuyingController(SubcontractingController): # Item tax amount is the total tax amount applied on that item and actual tax type amount item.item_tax_amount = flt( - item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item) + item_tax_amount + actual_charge_per_item.get(item.idx, 0.0), + self.precision("item_tax_amount", item), ) self.round_floats_in(item) @@ -503,7 +494,11 @@ class BuyingController(SubcontractingController): def get_tax_details(self): tax_accounts = [] total_valuation_amount = 0.0 - total_actual_tax_amount = 0.0 + # Per-row "Actual" valuation charge amounts, kept separate (not pooled) so each can be + # distributed individually - this keeps the per-item item_tax_amount in lockstep with the + # per-tax-row amount capitalized in the GL (see get_capitalized_valuation_tax). + all_item_charges = [] + stock_item_charges = [] for d in self.get("taxes"): if d.category not in ["Valuation", "Valuation and Total"]: @@ -516,10 +511,13 @@ class BuyingController(SubcontractingController): if d.charge_type == "On Net Total": total_valuation_amount += amount tax_accounts.append(d.account_head) + elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"): + # Capitalize the full amount onto stock/asset items only (e.g. Freight) + stock_item_charges.append(amount) else: - total_actual_tax_amount += amount + all_item_charges.append(amount) - return tax_accounts, total_valuation_amount, total_actual_tax_amount + return tax_accounts, total_valuation_amount, all_item_charges, stock_item_charges def get_item_tax_amount(self, item, tax_accounts): item_tax_amount = 0.0 @@ -540,16 +538,81 @@ class BuyingController(SubcontractingController): return item_tax_amount - def get_item_actual_tax_amount( - self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty - ): - item_proportion = ( - flt(item.base_net_amount) / stock_and_asset_items_amount - if stock_and_asset_items_amount - else flt(item.qty) / stock_and_asset_items_qty + def distribute_actual_tax_amount(self, stock_and_asset_items, all_item_charges, stock_item_charges): + """Distribute "Actual" valuation charges to each item, keyed by row idx. + + Each charge is spread individually (not pooled together) so the resulting per-item + item_tax_amount decomposes exactly into the per-tax-row amount capitalized in the GL + (see get_capitalized_valuation_tax) - pooling first and spreading the aggregate can drift + by rounding for multiple charges over unevenly valued items. A charge in `all_item_charges` + is spread across every item by net amount; a non-stock item's share is computed but never + capitalized (e.g. a genuine tax). A charge in `stock_item_charges` (flagged + `allocate_full_amount_to_stock_items`) is spread across stock/asset items only, so the whole + charge is capitalized (e.g. Freight). + """ + all_items = [d for d in self.get("items") if d.item_code] + stock_items = [d for d in all_items if d.item_code in stock_and_asset_items] + + charge_per_item = {} + for charge in all_item_charges: + self._spread_charge_over_items(charge_per_item, charge, all_items) + for charge in stock_item_charges: + self._spread_charge_over_items(charge_per_item, charge, stock_items) + return charge_per_item + + def _spread_charge_over_items(self, charge_per_item, total_charge, items): + """Add each item's proportional share of `total_charge` into `charge_per_item`. + Proportion is by net amount (falling back to qty); any rounding remainder is assigned + to the last item in the group.""" + if not total_charge or not items: + return + + total_amount = sum(flt(d.base_net_amount) for d in items) + total_qty = sum(flt(d.qty) for d in items) + + # Nothing to proportion against (all rows have zero amount and zero qty) + if not total_amount and not total_qty: + return + + remaining = total_charge + for d in items[:-1]: + proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty + charge = flt(proportion * total_charge, self.precision("item_tax_amount", d)) + charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge + remaining -= charge + + last = items[-1] + charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt( + remaining, self.precision("item_tax_amount", last) ) - return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item)) + def get_capitalized_valuation_tax(self): + stock_and_asset_items = self.get_stock_items() + self.get_asset_items() + all_items = [d for d in self.get("items") if d.item_code] + stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items} + + capitalized = {} + for tax in self.get("taxes"): + if tax.category not in ("Valuation", "Valuation and Total"): + continue + + amount = flt(tax.base_tax_amount_after_discount_amount) * ( + -1 if tax.get("add_deduct_tax") == "Deduct" else 1 + ) + if not amount: + continue + + if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"): + # Spread across all items; only the stock/asset items' share is capitalized. + charge_per_item = {} + self._spread_charge_over_items(charge_per_item, amount, all_items) + amount = sum( + charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx + ) + + capitalized[tax.name] = amount + + return capitalized def set_incoming_rate(self): """ diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 3d754bcdbd9..10b40b216bb 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -886,6 +886,12 @@ class PurchaseReceipt(BuyingController): def make_tax_gl_entries(self, gl_entries, via_landed_cost_voucher=False): negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get("items")]) + + # Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by + # tax row name. This is what must be credited to each tax account - a non-stock item's share + # of a spread-across-all-items charge is not capitalized, so it is excluded here. + capitalized_valuation_tax = self.get_capitalized_valuation_tax() + # Cost center-wise amount breakup for other charges included for valuation valuation_tax = {} for tax in self.get("taxes"): @@ -898,10 +904,8 @@ class PurchaseReceipt(BuyingController): tax.idx, _(tax.category) ) ) - valuation_tax.setdefault(tax.name, 0) - valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt( - tax.base_tax_amount_after_discount_amount - ) + + valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0) if negative_expense_to_be_booked and valuation_tax: # Backward compatibility: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 4500a9fbe79..52e3b8cf4f0 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1334,11 +1334,12 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr.delete() def test_valuation_tax_distribution_with_non_stock_item(self): - """A "Valuation and Total" tax is distributed across all items by net amount, but only - stock/asset items can carry valuation. For a document with 2 stock items + 1 service - item (each net 100) and a 30 valuation tax, each item's share is 10; only the two stock - items capitalize their share (20 total), so the non-stock item's 10 share must not be - capitalized onto the stock items.""" + """When "Allocate Full Amount to Stock Items" is unchecked, a "Valuation and Total" + actual charge is distributed across all items by net amount, but only stock/asset items + can carry valuation. For a document with 2 stock items + 1 service item (each net 100) + and a 30 valuation charge, each item's share is 10; only the two stock items capitalize + their share (20 total), so the non-stock item's 10 share must not be capitalized onto the + stock items.""" company = "_Test Company with perpetual inventory" warehouse = "Stores - TCP1" @@ -1373,6 +1374,8 @@ class TestPurchaseReceipt(ERPNextTestSuite): "cost_center": "Main - TCP1", "description": "Valuation Tax", "tax_amount": 30, + # Spread across all items (incl. non-stock); do not allocate full amount to stock items + "allocate_full_amount_to_stock_items": 0, }, ) @@ -1400,6 +1403,231 @@ class TestPurchaseReceipt(ERPNextTestSuite): # Only the stock items' share (20) is capitalized; the service item's 10 is excluded self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2) + def test_full_actual_charge_capitalized_on_stock_items_only(self): + """When "Allocate Full Amount to Stock Items" is checked (the default), an actual + valuation charge such as Freight is fully capitalized onto stock/asset items only. For a + document with 2 stock items + 1 service item (each net 100) and a 30 freight charge, the + charge is distributed over the 200 stock net only: 15 per stock item, and the entire 30 + is capitalized (nothing is lost to the non-stock item).""" + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + stock_item1 = make_item(properties={"is_stock_item": 1}).name + stock_item2 = make_item(properties={"is_stock_item": 1}).name + service_item = make_item(properties={"is_stock_item": 0}).name + + pr = frappe.new_doc("Purchase Receipt") + pr.company = company + pr.supplier = "_Test Supplier" + pr.currency = "INR" + # Order matters: stock, service, stock (service item in the middle) + for code in (stock_item1, service_item, stock_item2): + pr.append( + "items", + { + "item_code": code, + "qty": 1, + "rate": 100, + "warehouse": warehouse, + "cost_center": "Main - TCP1", + "expense_account": "Cost of Goods Sold - TCP1", + }, + ) + + pr.append( + "taxes", + { + "charge_type": "Actual", + "account_head": "_Test Account Shipping Charges - TCP1", + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": "Freight", + "tax_amount": 30, + # Default behavior: allocate the full amount to stock/asset items only + "allocate_full_amount_to_stock_items": 1, + }, + ) + + pr.insert() + + # 30 freight / 200 stock net = 15 per stock item. The service item carries nothing. + self.assertAlmostEqual(pr.items[0].item_tax_amount, 15.0, places=2) + self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2) + self.assertAlmostEqual(pr.items[2].item_tax_amount, 15.0, places=2) + self.assertAlmostEqual(pr.items[0].valuation_rate, 115.0, places=2) + self.assertAlmostEqual(pr.items[2].valuation_rate, 115.0, places=2) + + pr.submit() + + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True) + gl_map = {row.account: row for row in gl_entries} + + warehouse_account = get_warehouse_account_map(company) + stock_account = warehouse_account[warehouse]["account"] + + # Stock asset = 200 (goods) + 30 (the entire freight charge) + self.assertAlmostEqual(gl_map[stock_account].debit, 230.0, places=2) + self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2) + # The whole freight charge (30) is capitalized + self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 30.0, places=2) + + def test_actual_charge_distribution_with_both_allocation_modes(self): + """Both allocation modes can coexist on the same document, and each item's share from + each charge adds up. For 2 stock items + 1 service item (each net 100): + - a 30 charge with the flag unchecked spreads over all 3 items (10 each); the service + item's 10 is not capitalized, so each stock item keeps 10. + - a 20 charge with the flag checked spreads over the 2 stock items only (10 each). + So each stock item carries 10 + 10 = 20, and the service item carries nothing.""" + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + stock_item1 = make_item(properties={"is_stock_item": 1}).name + stock_item2 = make_item(properties={"is_stock_item": 1}).name + service_item = make_item(properties={"is_stock_item": 0}).name + + pr = frappe.new_doc("Purchase Receipt") + pr.company = company + pr.supplier = "_Test Supplier" + pr.currency = "INR" + # Order matters: stock, service, stock (service item in the middle) + for code in (stock_item1, service_item, stock_item2): + pr.append( + "items", + { + "item_code": code, + "qty": 1, + "rate": 100, + "warehouse": warehouse, + "cost_center": "Main - TCP1", + "expense_account": "Cost of Goods Sold - TCP1", + }, + ) + + # Spread across all items (service share dropped) + pr.append( + "taxes", + { + "charge_type": "Actual", + "account_head": "_Test Account Shipping Charges - TCP1", + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": "Valuation Tax", + "tax_amount": 30, + "allocate_full_amount_to_stock_items": 0, + }, + ) + # Allocate the full amount to stock items only + pr.append( + "taxes", + { + "charge_type": "Actual", + "account_head": "_Test Account Customs Duty - TCP1", + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": "Freight", + "tax_amount": 20, + "allocate_full_amount_to_stock_items": 1, + }, + ) + + pr.insert() + + # Each stock item: 10 (all-items charge) + 10 (stock-only charge) = 20 + self.assertAlmostEqual(pr.items[0].item_tax_amount, 20.0, places=2) + self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2) + self.assertAlmostEqual(pr.items[2].item_tax_amount, 20.0, places=2) + self.assertAlmostEqual(pr.items[0].valuation_rate, 120.0, places=2) + self.assertAlmostEqual(pr.items[2].valuation_rate, 120.0, places=2) + + pr.submit() + + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True) + gl_map = {row.account: row for row in gl_entries} + + warehouse_account = get_warehouse_account_map(company) + stock_account = warehouse_account[warehouse]["account"] + + # Stock asset = 200 (goods) + 20 (stock share of the spread charge) + 20 (the full freight) + self.assertAlmostEqual(gl_map[stock_account].debit, 240.0, places=2) + self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2) + # Only the stock items' 20 share of the spread charge is capitalized (service 10 excluded) + self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2) + # The whole freight charge (20) is capitalized + self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 20.0, places=2) + + def test_multiple_actual_charges_per_item_matches_gl_per_account(self): + """With multiple "Actual" valuation charges over unevenly valued stock items, each charge + is distributed individually so the per-item item_tax_amount decomposes exactly into the + per-tax-row amount capitalized in the GL (no rounding drift between the two paths). + + 2 stock items with net 100 and 200 (total 300), and two freight charges of 10 each, both + flagged to capitalize fully onto stock items. Distributing each charge separately gives + item1 = round(100/300*10) * 2 = 3.33 * 2 = 6.66 and item2 = 6.67 * 2 = 13.34. Pooling the + two charges into 20 first and spreading the aggregate would instead put 6.67 on item1, + which no longer matches the 3.33 + 3.33 implied by the two per-account GL credits.""" + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + stock_item1 = make_item(properties={"is_stock_item": 1}).name + stock_item2 = make_item(properties={"is_stock_item": 1}).name + + pr = frappe.new_doc("Purchase Receipt") + pr.company = company + pr.supplier = "_Test Supplier" + pr.currency = "INR" + for code, rate in ((stock_item1, 100), (stock_item2, 200)): + pr.append( + "items", + { + "item_code": code, + "qty": 1, + "rate": rate, + "warehouse": warehouse, + "cost_center": "Main - TCP1", + "expense_account": "Cost of Goods Sold - TCP1", + }, + ) + + for account, amount in ( + ("_Test Account Shipping Charges - TCP1", 10), + ("_Test Account Customs Duty - TCP1", 10), + ): + pr.append( + "taxes", + { + "charge_type": "Actual", + "account_head": account, + "category": "Valuation and Total", + "cost_center": "Main - TCP1", + "description": account, + "tax_amount": amount, + "allocate_full_amount_to_stock_items": 1, + }, + ) + + pr.insert() + + # Each charge spread on its own: 3.33 + 3.33 = 6.66 and 6.67 + 6.67 = 13.34 (total 20) + self.assertAlmostEqual(pr.items[0].item_tax_amount, 6.66, places=2) + self.assertAlmostEqual(pr.items[1].item_tax_amount, 13.34, places=2) + self.assertAlmostEqual(pr.items[0].valuation_rate, 106.66, places=2) + self.assertAlmostEqual(pr.items[1].valuation_rate, 213.34, places=2) + + pr.submit() + + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True) + gl_map = {row.account: row for row in gl_entries} + + warehouse_account = get_warehouse_account_map(company) + stock_account = warehouse_account[warehouse]["account"] + + # Stock asset = 300 (goods) + 10 + 10 (both freight charges fully capitalized) + self.assertAlmostEqual(gl_map[stock_account].debit, 320.0, places=2) + self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 300.0, places=2) + # Each charge is credited in full to its own account + self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 10.0, places=2) + self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 10.0, places=2) + def test_po_to_pi_and_po_to_pr_worflow_full(self): """Test following behaviour: - Create PO