Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 98 additions & 1 deletion hrms/payroll/doctype/income_tax_slab/income_tax_slab.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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} <br> 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
188 changes: 186 additions & 2 deletions hrms/payroll/doctype/income_tax_slab/test_income_tax_slab.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading