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):