diff --git a/hrms/hr/doctype/attendance_request/test_attendance_request.py b/hrms/hr/doctype/attendance_request/test_attendance_request.py
index 0ddbff4aab..165d15c2a5 100644
--- a/hrms/hr/doctype/attendance_request/test_attendance_request.py
+++ b/hrms/hr/doctype/attendance_request/test_attendance_request.py
@@ -163,6 +163,7 @@ def get_attendance_records(self, attendance_request: str) -> list[dict]:
)
def test_validate_no_attendance_to_create(self):
+ frappe.db.delete("Holiday", {"parent": self.holiday_list})
today = getdate()
yesterday = add_days(today, -1)
# marking absent for two days
diff --git a/hrms/payroll/doctype/income_tax_slab/income_tax_slab.py b/hrms/payroll/doctype/income_tax_slab/income_tax_slab.py
index 372afe9736..3569225703 100644
--- a/hrms/payroll/doctype/income_tax_slab/income_tax_slab.py
+++ b/hrms/payroll/doctype/income_tax_slab/income_tax_slab.py
@@ -1,12 +1,17 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+from datetime import date
+import frappe
+from frappe import _
from frappe.model.document import Document
+from frappe.utils import cstr, flt, get_first_day, get_last_day, getdate
-# import frappe
import erpnext
+from hrms.hr.utils import calculate_tax_with_marginal_relief
+
class IncomeTaxSlab(Document):
# begin: auto-generated types
@@ -37,3 +42,95 @@ class IncomeTaxSlab(Document):
def validate(self):
if self.company:
self.currency = erpnext.get_company_currency(self.company)
+
+
+def calculate_tax_by_tax_slab(annual_taxable_earning, tax_slab, eval_globals=None, eval_locals=None):
+ eval_globals = eval_globals or {}
+ eval_locals = eval_locals or {}
+
+ if annual_taxable_earning <= tax_slab.tax_relief_limit:
+ return 0, 0
+
+ tax_amount = calculate_base_tax_from_tax_slabs(
+ annual_taxable_earning, tax_slab, eval_globals, eval_locals
+ )
+
+ if tax_with_marginal_relief := calculate_tax_with_marginal_relief(
+ tax_slab, tax_amount, annual_taxable_earning
+ ):
+ tax_amount = tax_with_marginal_relief
+
+ tax_amount, surcharge = apply_surcharge_with_marginal_relief(
+ tax_amount, annual_taxable_earning, tax_slab, eval_globals, eval_locals
+ )
+
+ tax_amount, other_taxes = calculate_other_charges(tax_amount, annual_taxable_earning, tax_slab)
+ return tax_amount, surcharge + other_taxes
+
+
+def calculate_base_tax_from_tax_slabs(annual_taxable_earning, tax_slab, eval_globals, eval_locals):
+ tax_amount = 0
+ eval_locals.update({"annual_taxable_earning": annual_taxable_earning})
+
+ for slab in tax_slab.slabs:
+ cond = cstr(slab.condition).strip()
+ if cond and not eval_tax_slab_condition(cond, eval_globals, eval_locals):
+ continue
+ if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
+ tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
+ continue
+ if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
+ tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
+ elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
+ tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
+ return tax_amount
+
+
+def calculate_other_charges(tax_amount, annual_taxable_earning, tax_slab):
+ total_other_taxes_and_charges = 0
+ for d in tax_slab.other_taxes_and_charges:
+ if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
+ continue
+
+ if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
+ continue
+ other_taxes_and_charges = tax_amount * flt(d.percent) / 100
+ tax_amount += other_taxes_and_charges
+ total_other_taxes_and_charges += other_taxes_and_charges
+
+ return tax_amount, total_other_taxes_and_charges
+
+
+def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
+ if not eval_globals:
+ eval_globals = {
+ "int": int,
+ "float": float,
+ "long": int,
+ "round": round,
+ "date": date,
+ "getdate": getdate,
+ "get_first_day": get_first_day,
+ "get_last_day": get_last_day,
+ }
+ try:
+ condition = condition.strip()
+ if condition:
+ return frappe.safe_eval(condition, eval_globals, eval_locals)
+ except NameError as err:
+ frappe.throw(
+ _("{0}
This error can be due to missing or deleted field.").format(str(err)),
+ title=_("Name error"),
+ )
+ except SyntaxError as err:
+ frappe.throw(_("Syntax error in condition: {0} in Income Tax Slab").format(str(err)))
+ except Exception as e:
+ frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(str(e)))
+ raise
+
+
+@erpnext.allow_regional
+def apply_surcharge_with_marginal_relief(
+ tax_amount, annual_taxable_earning, tax_slab, eval_globals, eval_locals
+):
+ return tax_amount, 0
diff --git a/hrms/payroll/doctype/income_tax_slab/test_income_tax_slab.py b/hrms/payroll/doctype/income_tax_slab/test_income_tax_slab.py
index fb63f42abd..3612b10ed6 100644
--- a/hrms/payroll/doctype/income_tax_slab/test_income_tax_slab.py
+++ b/hrms/payroll/doctype/income_tax_slab/test_income_tax_slab.py
@@ -1,9 +1,193 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-# import frappe
+import frappe
+
+from erpnext.setup.doctype.employee.test_employee import make_employee
+
+from hrms.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
+ create_payroll_period,
+)
+from hrms.payroll.doctype.income_tax_slab.income_tax_slab import (
+ calculate_base_tax_from_tax_slabs,
+ calculate_other_charges,
+ calculate_tax_by_tax_slab,
+)
+from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
from hrms.tests.utils import HRMSTestSuite
+SIMPLE_SLABS = [
+ {"from_amount": 0, "to_amount": 300000, "percent_deduction": 0, "condition": ""},
+ {"from_amount": 300000, "to_amount": 700000, "percent_deduction": 5, "condition": ""},
+ {"from_amount": 700000, "to_amount": 1000000, "percent_deduction": 10, "condition": ""},
+ {"from_amount": 1000000, "to_amount": 0, "percent_deduction": 30, "condition": ""},
+]
+
class TestIncomeTaxSlab(HRMSTestSuite):
- pass
+ def setUp(self):
+ self.slab = make_income_tax_slab(SIMPLE_SLABS)
+ self.slab_with_cess = make_income_tax_slab(
+ SIMPLE_SLABS,
+ other_taxes_and_charges=[
+ {"description": "cess", "percent": 4, "min_taxable_income": 0, "max_taxable_income": 0}
+ ],
+ )
+
+ # calculate_other_charges
+
+ def test_other_charges_applied_as_flat_rate(self):
+ """Charges in other_taxes_and_charges are a flat percentage — no marginal relief logic."""
+ base_tax = calculate_base_tax_from_tax_slabs(1500000, self.slab_with_cess, None, {})
+
+ _, cess = calculate_other_charges(base_tax, 1500000, self.slab_with_cess)
+
+ self.assertEqual(cess, base_tax * 4 / 100)
+
+ def test_other_charges_no_marginal_relief_at_threshold(self):
+ """A surcharge row in other_taxes_and_charges at an income where marginal relief
+ would normally apply is still computed as a plain percentage — no relief reduction."""
+ # Income just above 50L — where marginal relief would apply if using surcharge_slabs
+ income = 5100000
+ slab = make_income_tax_slab(
+ SIMPLE_SLABS,
+ other_taxes_and_charges=[
+ {
+ "description": "Surcharge 10%",
+ "percent": 10,
+ "min_taxable_income": 0,
+ "max_taxable_income": 0,
+ }
+ ],
+ )
+ base_tax = calculate_base_tax_from_tax_slabs(income, slab, None, {})
+
+ _, surcharge = calculate_other_charges(base_tax, income, slab)
+
+ self.assertEqual(surcharge, base_tax * 10 / 100)
+
+ def test_other_charges_skipped_outside_income_range(self):
+ """Charges with min/max taxable income bounds are skipped when income is outside the range."""
+ slab = make_income_tax_slab(
+ SIMPLE_SLABS,
+ other_taxes_and_charges=[
+ {
+ "description": "High income cess",
+ "percent": 4,
+ "min_taxable_income": 10000000,
+ "max_taxable_income": 0,
+ }
+ ],
+ )
+ base_tax = calculate_base_tax_from_tax_slabs(1500000, slab, None, {})
+
+ _, charge = calculate_other_charges(base_tax, 1500000, slab)
+
+ self.assertEqual(charge, 0)
+
+ # calculate_tax_by_tax_slab
+
+ def test_zero_tax_below_relief_limit(self):
+ """Income at or below tax_relief_limit returns zero tax."""
+ slab = make_income_tax_slab(SIMPLE_SLABS, tax_relief_limit=700000)
+
+ tax, _ = calculate_tax_by_tax_slab(700000, slab, None, {})
+
+ self.assertEqual(tax, 0)
+
+ def test_tax_calculated_above_relief_limit(self):
+ """Income above tax_relief_limit produces non-zero tax."""
+ slab = make_income_tax_slab(SIMPLE_SLABS, tax_relief_limit=700000)
+
+ tax, _ = calculate_tax_by_tax_slab(800000, slab, None, {})
+
+ self.assertGreater(tax, 0)
+
+ def test_cess_computed_on_base_tax(self):
+ """other_taxes_and_charges (cess) is computed on the running tax total passed in."""
+ base_tax = calculate_base_tax_from_tax_slabs(1500000, self.slab_with_cess, None, {})
+ total_tax, charges = calculate_tax_by_tax_slab(1500000, self.slab_with_cess, None, {})
+
+ expected_cess = base_tax * 4 / 100
+ self.assertEqual(charges, expected_cess)
+ self.assertEqual(total_tax, base_tax + expected_cess)
+
+ def test_surcharge_then_cess_reflected_in_salary_slip_tds(self):
+ """Integration: TDS on a salary slip equals (base_tax + 10% surcharge + 4% cess) / 12.
+ Surcharge is applied first, so cess base is base_tax + surcharge — not base_tax alone."""
+ income_tax_slab = frappe.get_doc(
+ {
+ "doctype": "Income Tax Slab",
+ "name": "_Test Slab Surcharge Cess",
+ "effective_from": "2024-04-01",
+ "currency": "INR",
+ "tax_relief_limit": 0,
+ "slabs": [
+ {"from_amount": 0, "to_amount": 300000, "percent_deduction": 0},
+ {"from_amount": 300000, "to_amount": 700000, "percent_deduction": 5},
+ {"from_amount": 700000, "to_amount": 1000000, "percent_deduction": 10},
+ {"from_amount": 1000000, "to_amount": 0, "percent_deduction": 30},
+ ],
+ "other_taxes_and_charges": [
+ {
+ "description": "Surcharge 10%",
+ "percent": 10,
+ "min_taxable_income": 5000000,
+ "max_taxable_income": 0,
+ },
+ {
+ "description": "Cess 4%",
+ "percent": 4,
+ "min_taxable_income": 0,
+ "max_taxable_income": 0,
+ },
+ ],
+ }
+ )
+ income_tax_slab.insert(ignore_permissions=True)
+ income_tax_slab.submit()
+
+ payroll_period = create_payroll_period(
+ name="_Test Payroll Period Surcharge Cess", company="_Test Company"
+ )
+ employee = make_employee("test_surcharge_cess@salary.slip", company="_Test Company")
+
+ salary_structure = make_salary_structure(
+ "Structure for Surcharge Cess Test",
+ "Monthly",
+ test_tax=True,
+ employee=employee,
+ payroll_period=payroll_period,
+ company="_Test Company",
+ base=600000, # ₹7.2 M annual — in 10% surcharge band (min_taxable_income: 50L)
+ )
+
+ ssa_name = frappe.db.get_value("Salary Structure Assignment", {"employee": employee, "docstatus": 1})
+ frappe.db.set_value("Salary Structure Assignment", ssa_name, "income_tax_slab", income_tax_slab.name)
+
+ # Post on the first day of the payroll period → remaining_sub_periods = 12
+ salary_slip = make_salary_slip(
+ salary_structure.name,
+ employee=employee,
+ posting_date=payroll_period.start_date,
+ )
+
+ tds = next((d.amount for d in salary_slip.deductions if d.salary_component == "TDS"), 0)
+
+ annual_income = salary_slip.annual_taxable_amount
+ base_tax = calculate_base_tax_from_tax_slabs(annual_income, income_tax_slab, None, {})
+ surcharge = base_tax * 10 / 100
+ cess = (base_tax + surcharge) * 4 / 100
+ expected_tds = round((base_tax + surcharge + cess) / 12)
+
+ self.assertEqual(tds, expected_tds)
+
+
+def make_income_tax_slab(slabs, other_taxes_and_charges=None, tax_relief_limit=0):
+ return frappe._dict(
+ slabs=[frappe._dict(**s) for s in slabs],
+ surcharge_slabs=[],
+ other_taxes_and_charges=[frappe._dict(**c) for c in (other_taxes_and_charges or [])],
+ tax_relief_limit=tax_relief_limit,
+ )
diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py
index c11f951799..1fe9d09e02 100644
--- a/hrms/payroll/doctype/salary_slip/salary_slip.py
+++ b/hrms/payroll/doctype/salary_slip/salary_slip.py
@@ -40,6 +40,7 @@
create_employee_benefit_ledger_entry,
delete_employee_benefit_ledger_entry,
)
+from hrms.payroll.doctype.income_tax_slab.income_tax_slab import calculate_tax_by_tax_slab
from hrms.payroll.doctype.payroll_entry.payroll_entry import get_salary_withholdings, get_start_end_dates
from hrms.payroll.doctype.payroll_period.payroll_period import (
get_payroll_period,
@@ -2561,76 +2562,6 @@ def get_payroll_payable_account(company, payroll_entry):
return payroll_payable_account
-def calculate_tax_by_tax_slab(annual_taxable_earning, tax_slab, eval_globals=None, eval_locals=None):
- from hrms.hr.utils import calculate_tax_with_marginal_relief
-
- tax_amount = 0
- total_other_taxes_and_charges = 0
-
- if annual_taxable_earning > tax_slab.tax_relief_limit:
- eval_locals.update({"annual_taxable_earning": annual_taxable_earning})
-
- for slab in tax_slab.slabs:
- cond = cstr(slab.condition).strip()
- if cond and not eval_tax_slab_condition(cond, eval_globals, eval_locals):
- continue
- if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
- tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
- continue
-
- if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
- tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
- elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
- tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
-
- tax_with_marginal_relief = calculate_tax_with_marginal_relief(
- tax_slab, tax_amount, annual_taxable_earning
- )
- if tax_with_marginal_relief is not None:
- tax_amount = tax_with_marginal_relief
-
- for d in tax_slab.other_taxes_and_charges:
- if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
- continue
-
- if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
- continue
- other_taxes_and_charges = tax_amount * flt(d.percent) / 100
- tax_amount += other_taxes_and_charges
- total_other_taxes_and_charges += other_taxes_and_charges
-
- return tax_amount, total_other_taxes_and_charges
-
-
-def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
- if not eval_globals:
- eval_globals = {
- "int": int,
- "float": float,
- "long": int,
- "round": round,
- "date": date,
- "getdate": getdate,
- "get_first_day": get_first_day,
- "get_last_day": get_last_day,
- }
-
- try:
- condition = condition.strip()
- if condition:
- return frappe.safe_eval(condition, eval_globals, eval_locals)
- except NameError as err:
- frappe.throw(
- _("{0}
This error can be due to missing or deleted field.").format(err),
- title=_("Name error"),
- )
- except SyntaxError as err:
- frappe.throw(_("Syntax error in condition: {0} in Income Tax Slab").format(err))
- except Exception as e:
- frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
- raise
-
-
def get_lwp_or_ppl_for_date_range(employee, start_date, end_date):
LeaveApplication = frappe.qb.DocType("Leave Application")
LeaveType = frappe.qb.DocType("Leave Type")
diff --git a/hrms/payroll/report/income_tax_computation/income_tax_computation.py b/hrms/payroll/report/income_tax_computation/income_tax_computation.py
index 607eb6415d..e45f516748 100644
--- a/hrms/payroll/report/income_tax_computation/income_tax_computation.py
+++ b/hrms/payroll/report/income_tax_computation/income_tax_computation.py
@@ -6,8 +6,8 @@
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt, getdate, rounded
+from hrms.payroll.doctype.income_tax_slab.income_tax_slab import calculate_tax_by_tax_slab
from hrms.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates
-from hrms.payroll.doctype.salary_slip.salary_slip import calculate_tax_by_tax_slab
def execute(filters=None):