diff --git a/hrms/payroll/doctype/salary_component/salary_component.json b/hrms/payroll/doctype/salary_component/salary_component.json
index 7a8ce311ab..949fc6fb1a 100644
--- a/hrms/payroll/doctype/salary_component/salary_component.json
+++ b/hrms/payroll/doctype/salary_component/salary_component.json
@@ -69,7 +69,7 @@
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Type",
- "options": "Earning\nDeduction",
+ "options": "Earning\nDeduction\nEmployer Contribution",
"reqd": 1
},
{
@@ -89,6 +89,7 @@
},
{
"default": "0",
+ "depends_on": "eval:doc.type != \"Employer Contribution\"",
"fieldname": "do_not_include_in_total",
"fieldtype": "Check",
"label": "Do Not Include in Total"
@@ -118,6 +119,7 @@
},
{
"default": "0",
+ "depends_on": "eval:doc.type != \"Employer Contribution\"",
"description": "If enabled, the value specified or calculated in this component will not contribute to the earnings or deductions. However, it's value can be referenced by other components that can be added or deducted. ",
"fieldname": "statistical_component",
"fieldtype": "Check",
@@ -125,6 +127,7 @@
},
{
"default": "0",
+ "depends_on": "eval:doc.type != \"Employer Contribution\"",
"fieldname": "is_flexible_benefit",
"fieldtype": "Check",
"label": "Is Flexible Benefit"
@@ -150,7 +153,7 @@
"search_index": 1
},
{
- "depends_on": "eval:doc.statistical_component != 1",
+ "depends_on": "eval:doc.statistical_component != 1 && doc.type != \"Employer Contribution\"",
"fieldname": "section_break_5",
"fieldtype": "Section Break",
"label": "Accounts"
@@ -241,7 +244,7 @@
},
{
"default": "1",
- "depends_on": "eval:!doc.statistical_component",
+ "depends_on": "eval:!doc.statistical_component && doc.type != \"Employer Contribution\"",
"description": "If enabled, the component will not be displayed in the salary slip if the amount is zero",
"fieldname": "remove_if_zero_valued",
"fieldtype": "Check",
@@ -257,6 +260,7 @@
},
{
"default": "0",
+ "depends_on": "eval:doc.type != \"Employer Contribution\"",
"description": "If enabled, this component will be included in arrear calculations",
"fieldname": "arrear_component",
"fieldtype": "Check",
@@ -291,7 +295,7 @@
"icon": "fa fa-flag",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2026-03-26 15:07:25.464240",
+ "modified": "2026-06-08 17:39:39.847210",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Component",
diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py
index 4a7fd24a46..7b8f74d1ef 100644
--- a/hrms/payroll/doctype/salary_slip/salary_slip.py
+++ b/hrms/payroll/doctype/salary_slip/salary_slip.py
@@ -2,9 +2,6 @@
# License: GNU General Public License v3. See license.txt
-import unicodedata
-from datetime import date
-
import frappe
from frappe import _, msgprint
from frappe.model.document import Document
@@ -52,7 +49,12 @@
process_loan_interest_accrual_and_demand,
set_loan_repayment,
)
-from hrms.payroll.utils import sanitize_expression
+from hrms.payroll.utils import (
+ COMPONENT_EVAL_GLOBALS,
+ _safe_eval,
+ get_component_eval_context,
+ throw_error_message,
+)
from hrms.utils.holiday_list import get_holiday_dates_between
# cache keys
@@ -148,19 +150,7 @@ class SalarySlip(TransactionBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.default_series = f"Sal Slip/{self.employee}/.#####"
- self.whitelisted_globals = {
- "int": int,
- "float": float,
- "long": int,
- "round": round,
- "rounded": rounded,
- "date": date,
- "getdate": getdate,
- "get_first_day": get_first_day,
- "get_last_day": get_last_day,
- "ceil": ceil,
- "floor": floor,
- }
+ self.whitelisted_globals = COMPONENT_EVAL_GLOBALS.copy()
def autoname(self):
if not self.has_custom_naming_series:
@@ -453,36 +443,39 @@ def get_emp_and_working_day_details(self) -> None:
struct = self.check_sal_struct()
if struct:
- self.set_salary_structure_doc()
- self.salary_slip_based_on_timesheet = (
- self._salary_structure_doc.salary_slip_based_on_timesheet or 0
- )
- self.set_time_sheet()
- self.pull_sal_struct()
+ from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+
+ timesheet_config = self._get_ssa_doc().get_timesheet_config()
+ self.salary_slip_based_on_timesheet = timesheet_config.based_on_timesheet
+ if self.salary_slip_based_on_timesheet:
+ self._timesheet_component = timesheet_config.timesheet_component
+ self.set_time_sheet()
+ self.add_timesheet_earning_component(timesheet_config)
+ make_salary_slip(self.salary_structure, self)
process_loan_interest_accrual_and_demand(self)
def set_time_sheet(self):
- if self.salary_slip_based_on_timesheet:
- self.set("timesheets", [])
+ # caller (get_emp_and_working_day_details) gates this on salary_slip_based_on_timesheet
+ self.set("timesheets", [])
- Timesheet = frappe.qb.DocType("Timesheet")
- timesheets = (
- frappe.qb.from_(Timesheet)
- .select(Timesheet.star)
- .where(
- (Timesheet.employee == self.employee)
- & (Timesheet.start_date.between(self.start_date, self.end_date))
- & (
- (Timesheet.status == "Submitted")
- | (Timesheet.status == "Billed")
- | (Timesheet.status == "Partially Billed")
- )
+ Timesheet = frappe.qb.DocType("Timesheet")
+ timesheets = (
+ frappe.qb.from_(Timesheet)
+ .select(Timesheet.star)
+ .where(
+ (Timesheet.employee == self.employee)
+ & (Timesheet.start_date.between(self.start_date, self.end_date))
+ & (
+ (Timesheet.status == "Submitted")
+ | (Timesheet.status == "Billed")
+ | (Timesheet.status == "Partially Billed")
)
- ).run(as_dict=1)
+ )
+ ).run(as_dict=1)
- for data in timesheets:
- self.append("timesheets", {"time_sheet": data.name, "working_hours": data.total_hours})
+ for data in timesheets:
+ self.append("timesheets", {"time_sheet": data.name, "working_hours": data.total_hours})
def check_sal_struct(self):
ss = frappe.qb.DocType("Salary Structure")
@@ -526,19 +519,32 @@ def check_sal_struct(self):
title=_("Salary Structure Missing"),
)
- def pull_sal_struct(self):
- from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+ def add_timesheet_earning_component(self, timesheet_config):
+ self.hour_rate = flt(timesheet_config.hour_rate)
+ self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate)
+ self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0
+ wages_amount = self.hour_rate * self.total_working_hours
- if self.salary_slip_based_on_timesheet:
- self.salary_structure = self._salary_structure_doc.name
- self.hour_rate = self._salary_structure_doc.hour_rate
- self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate)
- self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0
- wages_amount = self.hour_rate * self.total_working_hours
+ self.add_earning_for_hourly_wages(self, timesheet_config.timesheet_component, wages_amount)
+
+ def add_earning_for_hourly_wages(self, doc, salary_component, amount):
+ row_exists = False
+ for row in doc.earnings:
+ if row.salary_component == salary_component:
+ row.amount = amount
+ row_exists = True
+ break
- self.add_earning_for_hourly_wages(self, self._salary_structure_doc.salary_component, wages_amount)
+ if not row_exists:
+ wages_row = get_salary_component_data(salary_component)
+ wages_amount = self.hour_rate * self.total_working_hours
- make_salary_slip(self._salary_structure_doc.name, self)
+ self.update_component_row(
+ wages_row,
+ wages_amount,
+ "earnings",
+ default_amount=wages_amount,
+ )
def get_working_days_details(self, lwp=None, for_preview=0, lwp_days_corrected=None):
payroll_settings = frappe.get_cached_value(
@@ -882,25 +888,6 @@ def calculate_lwp_ppl_and_absent_days_based_on_attendance(
return lwp, absent
- def add_earning_for_hourly_wages(self, doc, salary_component, amount):
- row_exists = False
- for row in doc.earnings:
- if row.salary_component == salary_component:
- row.amount = amount
- row_exists = True
- break
-
- if not row_exists:
- wages_row = get_salary_component_data(salary_component)
- wages_amount = self.hour_rate * self.total_working_hours
-
- self.update_component_row(
- wages_row,
- wages_amount,
- "earnings",
- default_amount=wages_amount,
- )
-
def set_salary_structure_assignment(self):
self._salary_structure_assignment = frappe.db.get_value(
"Salary Structure Assignment",
@@ -1195,7 +1182,7 @@ def compute_annual_deductions_before_tax_calculation(self):
current_period_exempted_amount += d.amount
# Future period exempted amount
- for deduction in self._salary_structure_doc.get("deductions"):
+ for deduction in self._evaluated_components["deductions"]:
if deduction.exempted_from_income_tax:
if deduction.amount_based_on_formula:
for sub_period in range(1, ceil(self.remaining_sub_periods)):
@@ -1247,8 +1234,8 @@ def calculate_component_amounts(self, component_type):
self.accrued_benefits = []
self.benefit_ledger_components = []
- if not getattr(self, "_salary_structure_doc", None):
- self.set_salary_structure_doc()
+ if not getattr(self, "_evaluated_components", None):
+ self._set_evaluated_components()
self.add_structure_components(component_type)
self.add_additional_salary_components(component_type)
@@ -1257,32 +1244,45 @@ def calculate_component_amounts(self, component_type):
else:
self.add_tax_components()
- def set_salary_structure_doc(self) -> None:
- self._salary_structure_doc = frappe.get_cached_doc("Salary Structure", self.salary_structure)
- # sanitize condition and formula fields
- for table in ("earnings", "deductions"):
- for row in self._salary_structure_doc.get(table):
- row.condition = sanitize_expression(row.condition)
- row.formula = sanitize_expression(row.formula)
+ def _set_evaluated_components(self) -> None:
+ """Ask the Salary Structure Assignment to evaluate all component formulas
+ once and return fully-resolved rows (with default_amount + flags). Shared
+ across the earnings and deductions passes so cross-component references
+ (e.g. a deduction referencing an earning abbr) resolve correctly."""
+ self._evaluated_components = self._get_ssa_doc().get_evaluated_components()
+
+ def _get_ssa_doc(self):
+ if not getattr(self, "_ssa_doc", None):
+ if not hasattr(self, "_salary_structure_assignment"):
+ self.set_salary_structure_assignment()
+ self._ssa_doc = frappe.get_cached_doc(
+ "Salary Structure Assignment", self._salary_structure_assignment.name
+ )
+ return self._ssa_doc
def add_structure_components(self, component_type):
self.data, self.default_data = self.get_data_for_eval()
- for struct_row in self._salary_structure_doc.get(component_type):
+ for struct_row in self._evaluated_components[component_type]:
self.add_structure_component(struct_row, component_type)
def add_structure_component(self, struct_row, component_type):
- if (
- self.salary_slip_based_on_timesheet
- and struct_row.salary_component == self._salary_structure_doc.salary_component
+ # the timesheet wage component is added separately (hour_rate * hours) in
+ # add_timesheet_earning_component, so skip it here to avoid double-adding
+ if self.salary_slip_based_on_timesheet and struct_row.salary_component == getattr(
+ self, "_timesheet_component", None
):
return
+ # struct_row is a resolved row from the Salary Structure Assignment carrying the
+ # component's formula/condition/flags. The slip evaluates it against its own context:
+ # - self.data: payment-days prorated values -> the actual `amount`
+ # - self.default_data: full-cycle values -> the `default_amount`
+ # (proration cascades through dependent formulas, e.g. SA = BS * 0.5 inherits BS's proration).
amount = self.eval_condition_and_formula(struct_row, self.data)
if struct_row.statistical_component or struct_row.accrual_component:
- # update statitical component amount in reference data based on payment days
+ # update statistical component amount in reference data based on payment days
# since row for statistical component is not added to salary slip
-
self.default_data[struct_row.abbr] = flt(amount)
if struct_row.depends_on_payment_days:
amount = (
@@ -1290,7 +1290,7 @@ def add_structure_component(self, struct_row, component_type):
if self.total_working_days
else 0
)
- self.data[struct_row.abbr] = flt(amount, struct_row.precision("amount"))
+ self.data[struct_row.abbr] = flt(amount, struct_row.precision)
is_accrual_component = (
component_type == "earnings"
@@ -1298,7 +1298,6 @@ def add_structure_component(self, struct_row, component_type):
and hasattr(self, "benefit_ledger_components")
)
if is_accrual_component:
- # add accrual component to Accrued Benefits table and track in Employee Benefit Ledger
self.append(
"accrued_benefits",
{
@@ -1330,7 +1329,9 @@ def add_structure_component(self, struct_row, component_type):
or (struct_row.amount_based_on_formula and amount is not None)
or (not remove_if_zero_valued and amount is not None and not self.data[struct_row.abbr])
):
- default_amount = self.eval_condition_and_formula(struct_row, self.default_data)
+ # full-cycle default comes from SSA (period-independent); the slip only
+ # computes the prorated `amount` above (proration is a period concern)
+ default_amount = flt(struct_row.default_amount)
self.update_component_row(
struct_row,
amount,
@@ -1342,19 +1343,17 @@ def add_structure_component(self, struct_row, component_type):
def get_data_for_eval(self):
"""Returns data for evaluating formula"""
- data = frappe._dict()
- employee = frappe.get_cached_doc("Employee", self.employee).as_dict()
-
if not hasattr(self, "_salary_structure_assignment"):
self.set_salary_structure_assignment()
- data.update(self._salary_structure_assignment)
+ data = get_component_eval_context(self.employee, self._salary_structure_assignment)
+ # Overlay salary-slip fields (payment_days, gross_pay, start_date, …) last, so the
+ # actual period context wins. Note: this means on a name collision a Salary Slip
+ # field takes precedence over an Employee field (employee is layered earlier in
+ # get_component_eval_context); no current formula relies on the reverse.
data.update(self.as_dict())
- data.update(employee)
- data.update(self.get_component_abbr_map())
-
- # shallow copy of data to store default amounts (without payment days) for tax calculation
+ # shallow copy to store default amounts (without payment-days proration) for tax calculation
default_data = data.copy()
for key in ("earnings", "deductions"):
@@ -1364,24 +1363,14 @@ def get_data_for_eval(self):
return data, default_data
- def get_component_abbr_map(self):
- def _fetch_component_values():
- return {
- component_abbr: 0
- for component_abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr")
- }
-
- return frappe.cache().get_value(SALARY_COMPONENT_VALUES, generator=_fetch_component_values)
-
def eval_condition_and_formula(self, struct_row, data):
try:
condition, formula, amount = struct_row.condition, struct_row.formula, struct_row.amount
if condition and not _safe_eval(condition, self.whitelisted_globals, data):
return None
if struct_row.amount_based_on_formula and formula:
- amount = flt(
- _safe_eval(formula, self.whitelisted_globals, data), struct_row.precision("amount")
- )
+ # struct_row is a evaluated row (frappe._dict) from the SSA; precision is carried as an int
+ amount = flt(_safe_eval(formula, self.whitelisted_globals, data), struct_row.precision)
if amount:
data[struct_row.abbr] = amount
@@ -1668,7 +1657,7 @@ def add_additional_salary_components(self, component_type):
def add_tax_components(self):
# Calculate variable_based_on_taxable_salary after all components updated in salary slip
tax_components, self.other_deduction_components = [], []
- for d in self._salary_structure_doc.get("deductions"):
+ for d in self._evaluated_components["deductions"]:
if d.variable_based_on_taxable_salary == 1 and not d.formula and not flt(d.amount):
tax_components.append(d.salary_component)
else:
@@ -2139,7 +2128,7 @@ def get_future_recurring_additional_amount(self, additional_salary, monthly_addi
def get_amount_based_on_payment_days(self, row):
amount, additional_amount = row.amount, row.additional_amount
- timesheet_component = self._salary_structure_doc.salary_component
+ timesheet_component = getattr(self, "_timesheet_component", None)
if not row.additional_salary and not row.default_amount:
amount, additional_amount = amount, additional_amount
@@ -2625,26 +2614,6 @@ def set_missing_values(time_sheet, target):
target.append("timesheets", {"time_sheet": doc.name, "working_hours": doc.total_hours})
-def throw_error_message(row, error, title, description=None):
- data = frappe._dict(
- {
- "doctype": row.parenttype,
- "name": row.parent,
- "doclink": get_link_to_form(row.parenttype, row.parent),
- "row_id": row.idx,
- "error": error,
- "title": title,
- "description": description or "",
- }
- )
-
- message = _(
- "Error while evaluating the {doctype} {doclink} at row {row_id}.
Error: {error}
Hint: {description}"
- ).format(**data)
-
- frappe.throw(message, title=title)
-
-
def verify_lwp_days_corrected(employee, start_date, end_date, lwp_days_corrected):
# Verify that the provided lwp_days_corrected matches actual payroll corrections.
PayrollCorrection = frappe.qb.DocType("Payroll Correction")
@@ -2680,50 +2649,6 @@ def on_doctype_update():
frappe.db.add_index("Salary Slip", ["employee", "start_date", "end_date"])
-def _safe_eval(code: str, eval_globals: dict | None = None, eval_locals: dict | None = None):
- """Old version of safe_eval from framework.
-
- Note: current frappe.safe_eval transforms code so if you have nested
- iterations with too much depth then it can hit recursion limit of python.
- There's no workaround for this and people need large formulas in some
- countries so this is alternate implementation for that.
-
- WARNING: DO NOT use this function anywhere else outside of this file.
- """
- code = unicodedata.normalize("NFKC", code)
-
- _check_attributes(code)
-
- whitelisted_globals = {"int": int, "float": float, "long": int, "round": round}
- if not eval_globals:
- eval_globals = {}
-
- eval_globals["__builtins__"] = {}
- eval_globals.update(whitelisted_globals)
- return eval(code, eval_globals, eval_locals) # nosemgrep
-
-
-def _check_attributes(code: str) -> None:
- import ast
-
- from frappe.utils.safe_exec import UNSAFE_ATTRIBUTES
-
- unsafe_attrs = set(UNSAFE_ATTRIBUTES).union(["__"]) - {"format"}
-
- for attribute in unsafe_attrs:
- if attribute in code:
- raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{attribute}"')
-
- BLOCKED_NODES = (ast.NamedExpr,)
-
- tree = ast.parse(code, mode="eval")
- for node in ast.walk(tree):
- if isinstance(node, BLOCKED_NODES):
- raise SyntaxError(f"Operation not allowed: line {node.lineno} column {node.col_offset}")
- if isinstance(node, ast.Attribute) and isinstance(node.attr, str) and node.attr in UNSAFE_ATTRIBUTES:
- raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{node.attr}"')
-
-
@frappe.whitelist()
def enqueue_email_salary_slips(names: list | str) -> None:
"""enqueue bulk emailing salary slips"""
diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.js b/hrms/payroll/doctype/salary_structure/salary_structure.js
index aab1263553..c75c9c5045 100755
--- a/hrms/payroll/doctype/salary_structure/salary_structure.js
+++ b/hrms/payroll/doctype/salary_structure/salary_structure.js
@@ -69,6 +69,12 @@ frappe.ui.form.on("Salary Structure", {
query: "hrms.payroll.doctype.salary_structure.salary_structure.get_salary_component",
};
});
+ frm.set_query("salary_component", "employer_contributions", function () {
+ return {
+ filters: { component_type: "employer contribution", company: frm.doc.company },
+ query: "hrms.payroll.doctype.salary_structure.salary_structure.get_salary_component",
+ };
+ });
},
company: function (frm) {
diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.json b/hrms/payroll/doctype/salary_structure/salary_structure.json
index b42449bd73..0eb9f359b9 100644
--- a/hrms/payroll/doctype/salary_structure/salary_structure.json
+++ b/hrms/payroll/doctype/salary_structure/salary_structure.json
@@ -27,6 +27,7 @@
"column_break_besp",
"earnings",
"deductions",
+ "employer_contributions",
"employee_benefits",
"conditions_and_formula_variable_and_example",
"net_pay_detail",
@@ -162,6 +163,12 @@
"oldfieldtype": "Table",
"options": "Salary Detail"
},
+ {
+ "fieldname": "employer_contributions",
+ "fieldtype": "Table",
+ "label": "Employer Contributions",
+ "options": "Salary Detail"
+ },
{
"fieldname": "net_pay_detail",
"fieldtype": "Section Break",
@@ -255,7 +262,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2025-09-15 15:56:52.814944",
+ "modified": "2026-06-08 17:39:39.847210",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Structure",
diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.py b/hrms/payroll/doctype/salary_structure/salary_structure.py
index d0e1749b79..a6d52d721f 100644
--- a/hrms/payroll/doctype/salary_structure/salary_structure.py
+++ b/hrms/payroll/doctype/salary_structure/salary_structure.py
@@ -169,7 +169,7 @@ def validate_timesheet_component(self):
break
def sanitize_condition_and_formula_fields(self):
- for table in ("earnings", "deductions"):
+ for table in ("earnings", "deductions", "employer_contributions"):
for row in self.get(table):
row.condition = row.condition.strip() if row.condition else ""
row.formula = row.formula.strip() if row.formula else ""
@@ -178,7 +178,7 @@ def sanitize_condition_and_formula_fields(self):
def reset_condition_and_formula_fields(self):
# set old values (allowing multiline strings for better readability in the doctype form)
- for table in ("earnings", "deductions"):
+ for table in ("earnings", "deductions", "employer_contributions"):
for row in self.get(table):
row.condition = row._condition
row.formula = row._formula
diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
index 767c24ec75..d5da6e5b8d 100644
--- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
+++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
@@ -22,9 +22,10 @@
"currency",
"section_break_7",
"base",
- "ctc",
+ "annual_gross_earning",
"column_break_9",
"variable",
+ "ctc",
"amended_from",
"column_break_kjvm",
"leave_encashment_amount_per_day",
@@ -117,6 +118,20 @@
"label": "Base",
"options": "currency"
},
+ {
+ "fieldname": "annual_gross_earning",
+ "fieldtype": "Currency",
+ "label": "Annual Gross Earning",
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "ctc",
+ "fieldtype": "Currency",
+ "label": "Total Cost To Company (CTC)",
+ "read_only": 1
+ },
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
@@ -243,17 +258,11 @@
"fieldtype": "Currency",
"label": "Leave Encashment Amount Per Day",
"options": "currency"
- },
- {
- "allow_on_submit": 1,
- "fieldname": "ctc",
- "fieldtype": "Currency",
- "label": "Total Cost To Company (CTC)"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2026-06-05 13:20:40.794089",
+ "modified": "2026-06-08 17:40:49.118187",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Structure Assignment",
diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
index 7f6ee935db..39ea6795f9 100644
--- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
+++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
@@ -5,10 +5,43 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, flt, get_link_to_form, getdate
+from frappe.utils import cint, date_diff, flt, get_link_to_form, getdate
from hrms.payroll.doctype.payroll_period.payroll_period import get_payroll_period
from hrms.payroll.doctype.salary_structure.salary_structure import validate_max_benefit_for_flexible_benefit
+from hrms.payroll.utils import (
+ COMPONENT_EVAL_GLOBALS,
+ _safe_eval,
+ get_component_eval_context,
+ sanitize_expression,
+ throw_error_message,
+)
+
+# Fields copied from the salary structure component row onto each evaluated row
+# handed to the salary slip. The slip reads these to build/identify slip rows.
+SALARY_COMPONENT_FLAGS = (
+ "salary_component",
+ "abbr",
+ "amount_based_on_formula",
+ "statistical_component",
+ "accrual_component",
+ "depends_on_payment_days",
+ "do_not_include_in_total",
+ "do_not_include_in_accounts",
+ "is_tax_applicable",
+ "is_flexible_benefit",
+ "variable_based_on_taxable_salary",
+ "exempted_from_income_tax",
+ "deduct_full_tax_on_selected_payroll_date",
+)
+
+PERIODS_PER_YEAR = {
+ "Monthly": 12,
+ "Fortnightly": 26,
+ "Bimonthly": 24,
+ "Weekly": 52,
+ "Daily": 365,
+}
class DuplicateAssignment(frappe.ValidationError):
@@ -28,6 +61,7 @@ class SalaryStructureAssignment(Document):
from hrms.payroll.doctype.employee_cost_center.employee_cost_center import EmployeeCostCenter
amended_from: DF.Link | None
+ annual_gross_earning: DF.Currency
base: DF.Currency
company: DF.Link
ctc: DF.Currency
@@ -62,6 +96,7 @@ def validate(self):
self.validate_cost_centers()
self.warn_about_missing_opening_entries()
+ self.calculate_ctc_and_gross()
def on_update_after_submit(self):
self.validate_cost_centers()
@@ -201,6 +236,187 @@ def warn_about_missing_opening_entries(self):
title=_("Missing Opening Entries"),
)
+ def get_evaluated_components(self) -> frappe._dict:
+ """Evaluate all salary structure components for this assignment and return
+ fully-evaluated rows the salary slip can consume directly.
+
+ Earnings, deductions and employer contributions are evaluated in one
+ shared pass (so a deduction formula can reference an earning abbr), each
+ row carrying its full-cycle ``default_amount`` plus the flags the slip
+ needs. This is period-independent: the (period-dependent) timesheet wage
+ is built by the salary slip itself, not here. The slip consumes
+ ``default_amount`` directly and applies payment-days proration / tax on
+ top (it re-evaluates each formula once against its prorated context for
+ the actual ``amount``).
+ """
+ _data, rows_by_type = self._evaluate_all_components()
+
+ return frappe._dict(
+ earnings=rows_by_type["earnings"],
+ deductions=rows_by_type["deductions"],
+ employer_contributions=rows_by_type["employer_contributions"],
+ )
+
+ def get_timesheet_config(self) -> frappe._dict:
+ """Lightweight read of the linked structure's timesheet settings, needed
+ by the slip early (before component evaluation runs)."""
+ ss = (
+ frappe.get_cached_value(
+ "Salary Structure",
+ self.salary_structure,
+ ["salary_slip_based_on_timesheet", "hour_rate", "salary_component"],
+ as_dict=True,
+ )
+ or frappe._dict()
+ )
+ return frappe._dict(
+ based_on_timesheet=cint(ss.salary_slip_based_on_timesheet),
+ hour_rate=flt(ss.hour_rate),
+ timesheet_component=ss.salary_component,
+ )
+
+ def calculate_ctc_and_gross(self) -> None:
+ if not self.base or not self.salary_structure:
+ self.annual_gross_earning = 0
+ self.ctc = 0
+ return
+
+ salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure)
+ periods = PERIODS_PER_YEAR.get(salary_structure.payroll_frequency, 12)
+
+ data, rows_by_type = self._evaluate_all_components()
+
+ # Reuse the per-period gross already computed in the eval context: payable
+ # earnings only (excludes statistical and do_not_include_in_total), matching
+ # the salary slip's gross_pay.
+ gross_per_period = flt(data.get("gross_pay"))
+
+ # CTC also includes costs that are part of CTC but not payable: do_not_include_in_total
+ # earnings (shown on the slip, excluded from gross) and employer contributions (off-slip).
+ non_payable_earnings_per_period = sum(
+ flt(r.default_amount)
+ for r in rows_by_type["earnings"]
+ if not r.statistical_component and r.do_not_include_in_total
+ )
+ employer_per_period = sum(
+ flt(r.default_amount)
+ for r in rows_by_type["employer_contributions"]
+ if not r.statistical_component
+ )
+
+ self.annual_gross_earning = flt(gross_per_period * periods, self.precision("annual_gross_earning"))
+ self.ctc = flt(
+ (gross_per_period + non_payable_earnings_per_period + employer_per_period) * periods,
+ self.precision("ctc"),
+ )
+
+ def _evaluate_all_components(self) -> tuple[frappe._dict, dict]:
+ """Single shared-context pass over earnings -> deductions ->
+ employer_contributions. Returns the final context and evaluated rows by
+ type. Does not mutate the cached salary structure doc."""
+ salary_structure = frappe.get_cached_doc("Salary Structure", self.salary_structure)
+ data = self._get_component_eval_context()
+
+ rows_by_type = {}
+ rows_by_type["earnings"] = self._evaluate_component_table(
+ salary_structure.get("earnings") or [], data
+ )
+
+ # Expose full-cycle gross_pay so deduction / employer-contribution formulas can
+ # reference it (e.g. PF, ESI), mirroring how the salary slip sets gross_pay after
+ # earnings and before deductions.
+ data["gross_pay"] = sum(
+ flt(r.default_amount)
+ for r in rows_by_type["earnings"]
+ if not r.statistical_component and not r.do_not_include_in_total
+ )
+
+ rows_by_type["deductions"] = self._evaluate_component_table(
+ salary_structure.get("deductions") or [], data
+ )
+ rows_by_type["employer_contributions"] = self._evaluate_component_table(
+ salary_structure.get("employer_contributions") or [], data
+ )
+ return data, rows_by_type
+
+ def _get_component_eval_context(self) -> frappe._dict:
+ from hrms.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates
+
+ data = get_component_eval_context(self.employee, self.as_dict())
+
+ # Full-cycle / preview seeding: SSA has no attendance, so it evaluates as a
+ # full period -- payment_days == total_working_days (proration ratio 1) and no
+ # LWP -- so formulas referencing slip-runtime fields resolve and yield
+ # full-cycle values. Compute the period day-count the same way the salary
+ # slip's for_preview does (days in the period containing from_date).
+ frequency = frappe.get_cached_value("Salary Structure", self.salary_structure, "payroll_frequency")
+ dates = get_start_end_dates(frequency, self.from_date, self.company)
+ period_days = date_diff(dates.end_date, dates.start_date) + 1
+ data.payment_days = period_days
+ data.total_working_days = period_days
+ data.leave_without_pay = 0
+ data.absent_days = 0
+ return data
+
+ def _evaluate_component_table(self, rows, data: frappe._dict) -> list:
+ """Evaluate one component table against the shared ``data`` (mutating it
+ with each component's full-cycle amount). Returns fresh ``frappe._dict``
+ rows (cache-safe copies). Raises a clear error on a bad formula/condition.
+ Rows whose condition is falsey are skipped (not added to the slip)."""
+ evaluated_components = []
+ for struct_row in rows:
+ condition = sanitize_expression(struct_row.condition)
+ formula = sanitize_expression(struct_row.formula)
+ amount = flt(struct_row.amount)
+
+ try:
+ if condition and not _safe_eval(condition, COMPONENT_EVAL_GLOBALS.copy(), data):
+ continue
+ if struct_row.amount_based_on_formula and formula:
+ default_amount = flt(
+ _safe_eval(formula, COMPONENT_EVAL_GLOBALS.copy(), data),
+ struct_row.precision("amount"),
+ )
+ else:
+ default_amount = amount
+ except NameError as ne:
+ throw_error_message(
+ struct_row,
+ ne,
+ title=_("Name error"),
+ description=_("This error can be due to missing or deleted field."),
+ )
+ except SyntaxError as se:
+ throw_error_message(
+ struct_row,
+ se,
+ title=_("Syntax error"),
+ description=_("This error can be due to invalid syntax."),
+ )
+ except Exception as exc:
+ throw_error_message(
+ struct_row,
+ exc,
+ title=_("Error in formula or condition"),
+ description=_("This error can be due to invalid formula or condition."),
+ )
+ raise
+
+ data[struct_row.abbr] = default_amount
+
+ evaluated_component_row = frappe._dict(
+ default_amount=default_amount,
+ amount=amount,
+ condition=condition,
+ formula=formula,
+ precision=struct_row.precision("amount"),
+ )
+ for field in SALARY_COMPONENT_FLAGS:
+ evaluated_component_row[field] = struct_row.get(field)
+ evaluated_components.append(evaluated_component_row)
+
+ return evaluated_components
+
@frappe.whitelist()
def are_opening_entries_required(self) -> bool:
if not get_tax_component(self.salary_structure):
diff --git a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py
index 9dee000e7f..ab7d2ee0c9 100644
--- a/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py
+++ b/hrms/payroll/doctype/salary_structure_assignment/test_salary_structure_assignment.py
@@ -1,8 +1,202 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+import frappe
+from frappe.utils import get_first_day, nowdate
+
+from erpnext.setup.doctype.employee.test_employee import make_employee
+
+from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
from hrms.tests.utils import HRMSTestSuite
+def _make_component(name, abbr, comp_type="Earning", **flags):
+ if frappe.db.exists("Salary Component", name):
+ frappe.delete_doc("Salary Component", name, force=True)
+ doc = frappe.new_doc("Salary Component")
+ doc.update({"salary_component": name, "salary_component_abbr": abbr, "type": comp_type})
+ doc.update(flags)
+ return doc.insert()
+
+
class TestSalaryStructureAssignment(HRMSTestSuite):
- pass
+ def test_ctc_and_annual_gross_exclude_statistical_and_include_employer(self):
+ """base=50000; gross = Basic (50000) only - statistical earning (1000) is
+ excluded; CTC = gross + employer contribution (6000)."""
+ emp = make_employee("ssa_ctc_calc@test.com", company="_Test Company")
+
+ _make_component("SSA Test Basic", "SSATB", "Earning", amount_based_on_formula=1, formula="base")
+ _make_component("SSA Test Statistical", "SSATS", "Earning", statistical_component=1)
+ _make_component("SSA Test Employer PF", "SSATEPF", "Employer Contribution")
+
+ earnings = [
+ {
+ "salary_component": "SSA Test Basic",
+ "abbr": "SSATB",
+ "amount_based_on_formula": 1,
+ "formula": "base",
+ },
+ {
+ "salary_component": "SSA Test Statistical",
+ "abbr": "SSATS",
+ "statistical_component": 1,
+ "amount": 1000,
+ },
+ ]
+ employer_contributions = [
+ {"salary_component": "SSA Test Employer PF", "abbr": "SSATEPF", "amount": 6000},
+ ]
+
+ make_salary_structure(
+ "SSA Test CTC Structure",
+ "Monthly",
+ employee=emp,
+ company="_Test Company",
+ base=50000,
+ earnings=earnings,
+ deductions=[],
+ other_details={"employer_contributions": employer_contributions},
+ )
+ ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp})
+
+ self.assertEqual(ssa.annual_gross_earning, 50000 * 12)
+ self.assertEqual(ssa.ctc, (50000 + 6000) * 12)
+
+ def test_ctc_reset_when_base_missing(self):
+ emp = make_employee("ssa_ctc_nobase@test.com", company="_Test Company")
+ make_salary_structure("SSA Test No Base Structure", "Monthly", company="_Test Company")
+ ssa = frappe.new_doc("Salary Structure Assignment")
+ ssa.employee = emp
+ ssa.salary_structure = "SSA Test No Base Structure"
+ ssa.company = "_Test Company"
+ ssa.from_date = get_first_day(nowdate())
+ ssa.base = 0
+ ssa.calculate_ctc_and_gross()
+ self.assertEqual(ssa.ctc, 0)
+ self.assertEqual(ssa.annual_gross_earning, 0)
+
+ def test_get_evaluated_components_does_not_mutate_cached_structure(self):
+ emp = make_employee("ssa_cache@test.com", company="_Test Company")
+ make_salary_structure(
+ "SSA Test Cache Structure", "Monthly", employee=emp, company="_Test Company", base=50000
+ )
+ ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp})
+
+ ssa.get_evaluated_components()
+ # the cached Salary Structure doc's earning rows must not carry a stamped default_amount
+ cached = frappe.get_cached_doc("Salary Structure", "SSA Test Cache Structure")
+ self.assertTrue(all(not r.get("default_amount") for r in cached.earnings))
+
+ def test_do_not_include_in_total_earning_is_in_ctc_but_not_gross(self):
+ """A 'Do Not Include in Total' earning is part of CTC but not payable - it
+ must be excluded from annual_gross_earning yet included in ctc."""
+ emp = make_employee("ssa_dniit@test.com", company="_Test Company")
+
+ _make_component("SSA Test Basic Pay", "SSATBP", "Earning", amount_based_on_formula=1, formula="base")
+ _make_component("SSA Test Company Car", "SSATCAR", "Earning", do_not_include_in_total=1)
+
+ earnings = [
+ {
+ "salary_component": "SSA Test Basic Pay",
+ "abbr": "SSATBP",
+ "amount_based_on_formula": 1,
+ "formula": "base",
+ },
+ {
+ "salary_component": "SSA Test Company Car",
+ "abbr": "SSATCAR",
+ "amount": 2000,
+ "do_not_include_in_total": 1,
+ },
+ ]
+
+ make_salary_structure(
+ "SSA Test DNIIT Structure",
+ "Monthly",
+ employee=emp,
+ company="_Test Company",
+ base=50000,
+ earnings=earnings,
+ deductions=[],
+ )
+ ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp})
+
+ self.assertEqual(ssa.annual_gross_earning, 50000 * 12)
+ self.assertEqual(ssa.ctc, (50000 + 2000) * 12)
+
+ def test_ctc_for_timesheet_structure_evaluates_base_driven_components(self):
+ """For a timesheet-based structure, only the wage component is paid as
+ hour_rate * hours (variable, excluded from CTC). The rest of the structure
+ (base-driven components) must still evaluate normally into gross / ctc."""
+ emp = make_employee("ssa_ts_ctc@test.com", company="_Test Company")
+
+ # wage component is the structure-level timesheet component, NOT an earning row
+ _make_component("SSA TS Wage", "SSATSW", "Earning")
+ _make_component("SSA TS Basic", "SSATSB", "Earning", amount_based_on_formula=1, formula="base")
+
+ earnings = [
+ {
+ "salary_component": "SSA TS Basic",
+ "abbr": "SSATSB",
+ "amount_based_on_formula": 1,
+ "formula": "base",
+ },
+ ]
+
+ make_salary_structure(
+ "SSA Timesheet Structure",
+ "Monthly",
+ employee=emp,
+ company="_Test Company",
+ base=50000,
+ earnings=earnings,
+ deductions=[],
+ other_details={
+ "salary_slip_based_on_timesheet": 1,
+ "hour_rate": 50,
+ "salary_component": "SSA TS Wage",
+ },
+ )
+ ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp})
+
+ # base-driven earning evaluates normally; the variable hourly wage is excluded from CTC
+ self.assertEqual(ssa.annual_gross_earning, 50000 * 12)
+ self.assertEqual(ssa.ctc, 50000 * 12)
+
+ def test_get_evaluated_components_excludes_timesheet_wage(self):
+ """SSA evaluation is period-independent: the timesheet wage (hour_rate * hours)
+ is built by the salary slip, not SSA. get_evaluated_components must return only
+ the structure's declared components, never an injected wage row."""
+ emp = make_employee("ssa_ts_eval@test.com", company="_Test Company")
+
+ _make_component("SSA TS2 Wage", "SSATS2W", "Earning")
+ _make_component("SSA TS2 Basic", "SSATS2B", "Earning", amount_based_on_formula=1, formula="base")
+
+ earnings = [
+ {
+ "salary_component": "SSA TS2 Basic",
+ "abbr": "SSATS2B",
+ "amount_based_on_formula": 1,
+ "formula": "base",
+ },
+ ]
+
+ make_salary_structure(
+ "SSA Timesheet Eval Structure",
+ "Monthly",
+ employee=emp,
+ company="_Test Company",
+ base=50000,
+ earnings=earnings,
+ deductions=[],
+ other_details={
+ "salary_slip_based_on_timesheet": 1,
+ "hour_rate": 50,
+ "salary_component": "SSA TS2 Wage",
+ },
+ )
+ ssa = frappe.get_last_doc("Salary Structure Assignment", filters={"employee": emp})
+
+ components = [r.salary_component for r in ssa.get_evaluated_components().earnings]
+ self.assertNotIn("SSA TS2 Wage", components)
+ self.assertIn("SSA TS2 Basic", components)
diff --git a/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py b/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py
index 6d27b982b1..f48bafa09b 100644
--- a/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py
+++ b/hrms/payroll/report/employee_ctc_break_up/test_employee_ctc_breakup.py
@@ -249,6 +249,8 @@ def test_ctc_validation(self):
salary_structure_assignment = create_salary_structure_assignment(
employee, salary_structure.name, base=60000, currency="INR", from_date=get_year_start(getdate())
)
+ # salary structure assignment auto calculates ctc on save, removing it manually for the test
+ salary_structure_assignment.db_set("ctc", 0)
self.assertRaises(
frappe.ValidationError, SalaryBreakupReport, employee, salary_structure_assignment.name
)
diff --git a/hrms/payroll/utils.py b/hrms/payroll/utils.py
index 61187336c2..9ec4c4ddc6 100644
--- a/hrms/payroll/utils.py
+++ b/hrms/payroll/utils.py
@@ -1,4 +1,9 @@
+import unicodedata
+from datetime import date
+
import frappe
+from frappe import _
+from frappe.utils import ceil, floor, get_first_day, get_last_day, get_link_to_form, getdate, rounded
def sanitize_expression(string: str | None = None) -> str | None:
@@ -26,6 +31,113 @@ def sanitize_expression(string: str | None = None) -> str | None:
return string
+COMPONENT_EVAL_GLOBALS = {
+ "int": int,
+ "float": float,
+ "long": int,
+ "round": round,
+ "rounded": rounded,
+ "date": date,
+ "getdate": getdate,
+ "get_first_day": get_first_day,
+ "get_last_day": get_last_day,
+ "ceil": ceil,
+ "floor": floor,
+ "min": min,
+ "max": max,
+}
+
+
+def get_component_abbr_map() -> dict:
+ """Cached {salary_component_abbr: 0} map, seeded into the formula eval context
+ so any component abbreviation referenced in a formula resolves (default 0).
+
+ Cache key matches salary_slip.SALARY_COMPONENT_VALUES (shared entry, invalidated
+ on Salary Component save)."""
+
+ def _fetch_component_values():
+ return {abbr: 0 for abbr in frappe.get_all("Salary Component", pluck="salary_component_abbr")}
+
+ return frappe.cache().get_value("salary_component_values", generator=_fetch_component_values)
+
+
+def get_component_eval_context(employee: str, ssa_as_dict: dict) -> frappe._dict:
+ """Build the base evaluation context for salary component formulas.
+
+ Merges component abbreviation defaults, Salary Structure Assignment fields
+ (base, variable, ...) and employee fields so that formulas can reference any
+ of them by name.
+ """
+ data = frappe._dict()
+ data.update(get_component_abbr_map())
+ data.update(ssa_as_dict)
+ data.update(frappe.get_cached_doc("Employee", employee).as_dict())
+ return data
+
+
+def _check_attributes(code: str) -> None:
+ import ast
+
+ from frappe.utils.safe_exec import UNSAFE_ATTRIBUTES
+
+ unsafe_attrs = set(UNSAFE_ATTRIBUTES).union(["__"]) - {"format"}
+
+ for attribute in unsafe_attrs:
+ if attribute in code:
+ raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{attribute}"')
+
+ BLOCKED_NODES = (ast.NamedExpr,)
+
+ tree = ast.parse(code, mode="eval")
+ for node in ast.walk(tree):
+ if isinstance(node, BLOCKED_NODES):
+ raise SyntaxError(f"Operation not allowed: line {node.lineno} column {node.col_offset}")
+ if isinstance(node, ast.Attribute) and isinstance(node.attr, str) and node.attr in UNSAFE_ATTRIBUTES:
+ raise SyntaxError(f'Illegal rule {frappe.bold(code)}. Cannot use "{node.attr}"')
+
+
+def _safe_eval(code: str, eval_globals: dict | None = None, eval_locals: dict | None = None):
+ """Safe eval for **trusted** salary component conditions and formulas only.
+
+ Uses AST-based attribute checking instead of frappe.safe_eval to avoid
+ recursion limit issues with the large/deeply-nested formulas some countries'
+ payroll needs. It is a lighter (denylist-based) sandbox than frappe.safe_eval,
+ so it is safe only for admin-authored salary-structure formulas, not arbitrary
+ or end-user input. For anything else, use frappe.safe_eval.
+ """
+ code = unicodedata.normalize("NFKC", code)
+
+ _check_attributes(code)
+
+ whitelisted_globals = {"int": int, "float": float, "long": int, "round": round}
+ if not eval_globals:
+ eval_globals = {}
+
+ eval_globals["__builtins__"] = {}
+ eval_globals.update(whitelisted_globals)
+ return eval(code, eval_globals, eval_locals) # nosemgrep
+
+
+def throw_error_message(row, error, title, description=None):
+ data = frappe._dict(
+ {
+ "doctype": row.parenttype,
+ "name": row.parent,
+ "doclink": get_link_to_form(row.parenttype, row.parent),
+ "row_id": row.idx,
+ "error": error,
+ "title": title,
+ "description": description or "",
+ }
+ )
+
+ message = _(
+ "Error while evaluating the {doctype} {doclink} at row {row_id}.
Error: {error}
Hint: {description}"
+ ).format(**data)
+
+ frappe.throw(message, title=title)
+
+
@frappe.whitelist()
def get_payroll_settings_for_payment_days() -> dict:
return frappe.get_cached_value(