hr_lite 0.5.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +97 -0
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +6 -2
- data/app/models/hr_lite/professional_tax_slab.rb +41 -0
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/models/hr_lite/statutory_rate_card_record.rb +95 -0
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
- data/app/services/hr_lite/payroll_run_processor.rb +17 -0
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +8 -1
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/statutory_rate_card.rb +55 -10
- data/lib/hr_lite/statutory_seeds.rb +71 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +64 -10
- data/lib/tasks/hr_lite_tasks.rake +18 -2
- metadata +24 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Adding a financial year, and recording who checked it. Money tier: these
|
|
4
|
+
# figures decide what lands in a bank account.
|
|
5
|
+
#
|
|
6
|
+
# New years are created by COPYING the newest card — a year's PF and ESI
|
|
7
|
+
# figures usually carry over untouched and only the slabs move, so a blank
|
|
8
|
+
# form would be an invitation to mistype eight numbers that were already
|
|
9
|
+
# right.
|
|
10
|
+
class StatutoryRateCardsController < SuperadminController
|
|
11
|
+
def index
|
|
12
|
+
@cards = StatutoryRateCardRecord.chronological.reverse
|
|
13
|
+
@states = ProfessionalTaxSlab.distinct.order(:state).pluck(:state)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def new
|
|
17
|
+
newest = StatutoryRateCardRecord.chronological.last
|
|
18
|
+
@card = StatutoryRateCardRecord.new(
|
|
19
|
+
effective_from: next_financial_year(newest),
|
|
20
|
+
pf: newest&.pf || {}, esi: newest&.esi || {}, income_tax: newest&.income_tax || {}
|
|
21
|
+
)
|
|
22
|
+
@copied_from = newest
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create
|
|
26
|
+
@card = StatutoryRateCardRecord.new(card_params)
|
|
27
|
+
if @card.save
|
|
28
|
+
redirect_to admin_statutory_rate_cards_path,
|
|
29
|
+
notice: "FY #{@card.financial_year} card saved. Payroll will use it " \
|
|
30
|
+
"from #{@card.effective_from.strftime('%d %b %Y')}."
|
|
31
|
+
else
|
|
32
|
+
@copied_from = nil
|
|
33
|
+
render :new, status: :unprocessable_entity
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def edit
|
|
38
|
+
@card = StatutoryRateCardRecord.find(params[:id])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def update
|
|
42
|
+
@card = StatutoryRateCardRecord.find(params[:id])
|
|
43
|
+
if @card.update(card_params)
|
|
44
|
+
redirect_to admin_statutory_rate_cards_path, notice: "FY #{@card.financial_year} card updated."
|
|
45
|
+
else
|
|
46
|
+
render :edit, status: :unprocessable_entity
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def next_financial_year(newest)
|
|
53
|
+
return FinancialYear.start_for(Date.current) if newest.nil?
|
|
54
|
+
|
|
55
|
+
[ newest.effective_from.next_year, FinancialYear.start_for(Date.current) ].max
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The figures arrive as flat strings from number fields and are stored
|
|
59
|
+
# as strings in JSON — a float would not survive the round trip intact,
|
|
60
|
+
# and these decide deductions.
|
|
61
|
+
def card_params
|
|
62
|
+
permitted = params.require(:statutory_rate_card)
|
|
63
|
+
.permit(:effective_from, :verified_by, :verified_on, :notes,
|
|
64
|
+
pf: {}, esi: {}, income_tax: {})
|
|
65
|
+
permitted.to_h.tap do |attrs|
|
|
66
|
+
attrs["income_tax"] = rebuild_regimes(attrs["income_tax"]) if attrs["income_tax"]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A blank `to` is the open-ended top slab and has to stay nil rather
|
|
71
|
+
# than becoming "" — the calculators read nil as "no upper bound".
|
|
72
|
+
def rebuild_regimes(income_tax)
|
|
73
|
+
income_tax.to_h do |regime, table|
|
|
74
|
+
rows = slab_rows(table["slabs"]).reject { |from, _to, _rate| from.blank? }
|
|
75
|
+
[ regime, table.except("slabs").merge(
|
|
76
|
+
"slabs" => rows.map { |from, to, rate| [ from, to.presence, rate ] },
|
|
77
|
+
"marginal_relief" => truthy?(table["marginal_relief"])
|
|
78
|
+
) ]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Two shapes reach here. The form posts index-keyed rows
|
|
83
|
+
# (`slabs[0][from]`), which Rails hands over as a hash. A card being
|
|
84
|
+
# copied — or a client posting one back unchanged — carries the stored
|
|
85
|
+
# array of [from, to, rate] triples.
|
|
86
|
+
def slab_rows(slabs)
|
|
87
|
+
case slabs
|
|
88
|
+
when Hash then slabs.values.map { |row| [ row["from"], row["to"], row["rate"] ] }
|
|
89
|
+
when Array then slabs.map { |row| Array(row).first(3) }
|
|
90
|
+
else []
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def truthy?(value) = [ true, "1", "true" ].include?(value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
# Money
|
|
4
|
-
# promotions.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
3
|
+
# Money screens: salary structures, payroll, slips, appraisals and
|
|
4
|
+
# promotions. Ordinary governing authority reaches none of it — leadership
|
|
5
|
+
# governs policy and people but never sees another person's pay.
|
|
6
|
+
#
|
|
7
|
+
# The money gate stands on its own rather than layering on the governing
|
|
8
|
+
# one: a payroll operator does not have to also govern people and policy.
|
|
9
|
+
# Stacking the two locked a configured money-tier user who was absent from
|
|
10
|
+
# the leadership list out of payroll entirely.
|
|
10
11
|
class SuperadminController < LeadershipController
|
|
11
|
-
skip_before_action :
|
|
12
|
-
before_action :
|
|
12
|
+
skip_before_action :require_governing_access!
|
|
13
|
+
before_action :require_money_access!
|
|
13
14
|
|
|
14
15
|
private
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Deliberately payroll.manage rather than a "super admin" role name:
|
|
18
|
+
# roles are data an install re-cuts, and gating on a NAME would make
|
|
19
|
+
# renaming one a silent lock-out.
|
|
20
|
+
def require_money_access!
|
|
21
|
+
hr_require_permission!("payroll.manage", scope: :all)
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -10,10 +10,48 @@ module HrLite
|
|
|
10
10
|
before_action :hr_set_current_actor
|
|
11
11
|
around_action :hr_use_time_zone
|
|
12
12
|
|
|
13
|
-
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
|
|
13
|
+
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name,
|
|
14
|
+
:hr_can?, :hr_reaches?, :hr_access
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
# --- authorization -----------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def hr_access
|
|
21
|
+
HrLite::Access.for(hr_current_user)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# May the signed-in person do this at all, at `scope` or wider?
|
|
25
|
+
def hr_can?(key, scope: :self)
|
|
26
|
+
hr_access.can?(key, scope: scope)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# May they do it to THIS person's records? The question the module could
|
|
30
|
+
# not ask before roles, and whose absence let any admin approve anybody's
|
|
31
|
+
# leave.
|
|
32
|
+
def hr_reaches?(key, subject_user)
|
|
33
|
+
hr_access.reaches?(key, subject_user)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Gate a whole controller or action. `scope` is the weakest scope that can
|
|
37
|
+
# reach the screen at all — a manager reaching a team screen still has
|
|
38
|
+
# every ROW checked separately by `hr_require_reach!`.
|
|
39
|
+
def hr_require_permission!(key, scope: :self)
|
|
40
|
+
hr_access_denied unless hr_can?(key, scope: scope)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Gate one record. Deliberately a 404, not a 403: whether a particular
|
|
44
|
+
# person exists is not something a stranger gets to learn from us.
|
|
45
|
+
def hr_require_reach!(key, subject_user)
|
|
46
|
+
raise ActiveRecord::RecordNotFound unless hr_reaches?(key, subject_user)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Narrow a relation to the rows this permission reaches, so an index
|
|
50
|
+
# never renders a row the member action would refuse.
|
|
51
|
+
def hr_scope(relation, key, column: :user_id)
|
|
52
|
+
hr_access.scope_relation(relation, key, column: column)
|
|
53
|
+
end
|
|
54
|
+
|
|
17
55
|
def hr_authenticate!
|
|
18
56
|
send(HrLite.config.authenticate_method)
|
|
19
57
|
end
|
|
@@ -24,9 +24,13 @@ module HrLite
|
|
|
24
24
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
-
# Money
|
|
27
|
+
# Money screens, plus role management: granting somebody payroll is the
|
|
28
|
+
# same act as holding it, so it sits with the pay rather than with the
|
|
29
|
+
# other governing screens.
|
|
28
30
|
SUPERADMIN_NAV_ITEMS = [
|
|
29
|
-
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
|
|
31
|
+
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
32
|
+
{ label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
|
|
33
|
+
{ label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] }
|
|
30
34
|
].freeze
|
|
31
35
|
|
|
32
36
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One state's professional-tax band. PT is a state levy with its own slabs
|
|
3
|
+
# and its own revision schedule, so it is dated separately from the central
|
|
4
|
+
# rate card rather than living inside it.
|
|
5
|
+
class ProfessionalTaxSlab < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
self.table_name = "hr_lite_professional_tax_slabs"
|
|
9
|
+
|
|
10
|
+
# A state that genuinely levies nothing. Distinct from a state nobody has
|
|
11
|
+
# configured — see `configured?`, which is what stops payroll quietly
|
|
12
|
+
# deducting zero for a state whose slabs simply were never entered.
|
|
13
|
+
NO_LEVY = "none".freeze
|
|
14
|
+
|
|
15
|
+
validates :state, presence: true
|
|
16
|
+
validates :effective_from, presence: true
|
|
17
|
+
validates :from_amount, :monthly, presence: true,
|
|
18
|
+
numericality: { greater_than_or_equal_to: 0 }
|
|
19
|
+
validates :feb_extra, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
20
|
+
|
|
21
|
+
scope :for_state, ->(state) { where(state: state.to_s) }
|
|
22
|
+
|
|
23
|
+
# The bands in force for a state in a given month: the newest effective
|
|
24
|
+
# date that is not in the future, and every band carrying it.
|
|
25
|
+
def self.effective_for(state, period_month)
|
|
26
|
+
dates = for_state(state).where(effective_from: ..period_month)
|
|
27
|
+
newest = dates.maximum(:effective_from)
|
|
28
|
+
return none if newest.nil?
|
|
29
|
+
|
|
30
|
+
for_state(state).where(effective_from: newest).order(:from_amount)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The shape Calculators::ProfessionalTax reads.
|
|
34
|
+
def self.table_for(state, period_month)
|
|
35
|
+
effective_for(state, period_month).map do |slab|
|
|
36
|
+
{ from: Money.d(slab.from_amount), monthly: Money.d(slab.monthly),
|
|
37
|
+
feb_extra: slab.feb_extra && Money.d(slab.feb_extra) }.compact
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A named bundle of grants. Roles are DATA — the seeded six are a starting
|
|
3
|
+
# point an install is expected to edit, not a fixed ladder in code.
|
|
4
|
+
class Role < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
# Seeded by name. `Employee` in particular is load-bearing: it is what a
|
|
8
|
+
# new hire is given so that self-service works on day one.
|
|
9
|
+
EMPLOYEE = "Employee".freeze
|
|
10
|
+
MANAGER = "Manager".freeze
|
|
11
|
+
HR = "HR".freeze
|
|
12
|
+
FINANCE = "Finance".freeze
|
|
13
|
+
LEADERSHIP = "Leadership".freeze
|
|
14
|
+
SUPER_ADMIN = "Super Admin".freeze
|
|
15
|
+
|
|
16
|
+
has_many :role_grants, dependent: :destroy
|
|
17
|
+
has_many :role_assignments, dependent: :destroy
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
20
|
+
before_destroy :protect_system_role
|
|
21
|
+
validate :system_role_keeps_its_name, on: :update
|
|
22
|
+
|
|
23
|
+
scope :alphabetical, -> { order(:name) }
|
|
24
|
+
|
|
25
|
+
# Every key this role grants, mapped to its scope. The shape the access
|
|
26
|
+
# resolver merges.
|
|
27
|
+
def grant_map
|
|
28
|
+
role_grants.to_h { |grant| [ grant.permission_key, grant.scope ] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replaces the whole grant set in one transaction — the roles screen posts
|
|
32
|
+
# the complete picture, so a permission absent from the form is a
|
|
33
|
+
# permission being taken away, not one left alone.
|
|
34
|
+
def replace_grants!(grants)
|
|
35
|
+
transaction do
|
|
36
|
+
role_grants.destroy_all
|
|
37
|
+
grants.each do |key, scope|
|
|
38
|
+
next if scope.blank? || scope.to_s == "none"
|
|
39
|
+
|
|
40
|
+
role_grants.create!(permission_key: Permissions.validate!(key), scope: scope.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
reload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def system? = self[:system]
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def protect_system_role
|
|
51
|
+
return unless system?
|
|
52
|
+
|
|
53
|
+
errors.add(:base, "#{name} is a built-in role and cannot be deleted")
|
|
54
|
+
throw :abort
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The name is the identifier the seed, the upgrade path and the specs all
|
|
58
|
+
# use. Permissions on a system role are free to change; what it is called
|
|
59
|
+
# is not.
|
|
60
|
+
def system_role_keeps_its_name
|
|
61
|
+
return unless system? && name_changed?
|
|
62
|
+
|
|
63
|
+
errors.add(:name, "cannot be changed on a built-in role")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who holds which role. Assignments are audited because granting somebody
|
|
3
|
+
# the money tier is exactly the kind of change that has to be explainable
|
|
4
|
+
# afterwards.
|
|
5
|
+
class RoleAssignment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :role
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :granted_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :user_id, uniqueness: { scope: :role_id }
|
|
13
|
+
|
|
14
|
+
scope :for_user, ->(user) { where(user_id: user.id) }
|
|
15
|
+
|
|
16
|
+
def hr_lite_audit_label = "#{HrLite.display_name(user)} — #{role.name}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One (role, permission, scope) triple. The permission key is validated
|
|
3
|
+
# against the declared registry, so a typo is a refused write rather than a
|
|
4
|
+
# grant that silently never matches anything.
|
|
5
|
+
class RoleGrant < ApplicationRecord
|
|
6
|
+
belongs_to :role
|
|
7
|
+
|
|
8
|
+
validates :permission_key, presence: true,
|
|
9
|
+
inclusion: { in: Permissions::KEYS, message: "is not a known permission" },
|
|
10
|
+
uniqueness: { scope: :role_id }
|
|
11
|
+
validates :scope, inclusion: { in: Permissions::SCOPES.map(&:to_s) }
|
|
12
|
+
|
|
13
|
+
def covers?(needed_scope) = Permissions.scope_covers?(scope, needed_scope)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A financial year's statutory figures, as data. Named for the table rather
|
|
3
|
+
# than the lookup module (HrLite::StatutoryRateCard) that reads it — hosts
|
|
4
|
+
# and specs call the module, not this.
|
|
5
|
+
class StatutoryRateCardRecord < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
self.table_name = "hr_lite_statutory_rate_cards"
|
|
9
|
+
|
|
10
|
+
validates :effective_from, presence: true, uniqueness: true
|
|
11
|
+
validate :effective_from_opens_a_financial_year
|
|
12
|
+
validate :carries_every_figure_payroll_reads
|
|
13
|
+
|
|
14
|
+
scope :chronological, -> { order(:effective_from) }
|
|
15
|
+
|
|
16
|
+
def self.effective_for(period_month)
|
|
17
|
+
where(effective_from: ..period_month).order(effective_from: :desc).first ||
|
|
18
|
+
chronological.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def financial_year = FinancialYear.label(effective_from)
|
|
22
|
+
|
|
23
|
+
def verified? = verified_by.present? && verified_on.present?
|
|
24
|
+
|
|
25
|
+
# The shape SlipBuilder and the calculators expect: symbol keys and
|
|
26
|
+
# BigDecimal everywhere, because JSON gives back strings and floats and
|
|
27
|
+
# a float has no business in a PF calculation.
|
|
28
|
+
def to_card
|
|
29
|
+
{
|
|
30
|
+
pf: deep_decimalize(pf).symbolize_keys,
|
|
31
|
+
esi: deep_decimalize(esi).symbolize_keys,
|
|
32
|
+
pt: {}, # slabs live in their own table — resolved by state at use
|
|
33
|
+
income_tax: income_tax.transform_values { |regime| decimalize_regime(regime) }
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def decimalize_regime(regime)
|
|
40
|
+
regime = regime.symbolize_keys
|
|
41
|
+
regime.merge(
|
|
42
|
+
standard_deduction: Money.d(regime[:standard_deduction]),
|
|
43
|
+
rebate_cap: Money.d(regime[:rebate_cap]),
|
|
44
|
+
cess_rate: Money.d(regime[:cess_rate]),
|
|
45
|
+
marginal_relief: !!regime[:marginal_relief],
|
|
46
|
+
slabs: Array(regime[:slabs]).map { |from, to, rate|
|
|
47
|
+
[ Money.d(from), to.nil? ? nil : Money.d(to), Money.d(rate) ]
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def deep_decimalize(hash)
|
|
53
|
+
hash.to_h { |key, value| [ key, Money.d(value) ] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A card dated mid-year would mean two sets of slabs inside one financial
|
|
57
|
+
# year, and the TDS projector works on an annual figure — there is no
|
|
58
|
+
# sensible answer to "which slabs applied to this year's income" then.
|
|
59
|
+
def effective_from_opens_a_financial_year
|
|
60
|
+
return if effective_from.blank?
|
|
61
|
+
return if effective_from == FinancialYear.start_for(effective_from)
|
|
62
|
+
|
|
63
|
+
errors.add(:effective_from, "must be 1 April — a card covers a whole financial year")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
REQUIRED = {
|
|
67
|
+
"pf" => %w[employee_rate employer_rate eps_rate wage_ceiling eps_wage_ceiling
|
|
68
|
+
edli_rate edli_ceiling admin_rate],
|
|
69
|
+
"esi" => %w[employee_rate employer_rate gross_ceiling]
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
# A card missing a figure does not fail at save time; it fails halfway
|
|
73
|
+
# through computing somebody's salary, as a NoMethodError on nil.
|
|
74
|
+
def carries_every_figure_payroll_reads
|
|
75
|
+
REQUIRED.each do |attribute, keys|
|
|
76
|
+
missing = keys - Array(public_send(attribute)&.keys)
|
|
77
|
+
errors.add(attribute, "is missing #{missing.join(', ')}") if missing.any?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
%w[new old].each do |regime|
|
|
81
|
+
table = income_tax[regime]
|
|
82
|
+
next errors.add(:income_tax, "is missing the #{regime} regime") if table.blank?
|
|
83
|
+
|
|
84
|
+
missing = %w[standard_deduction rebate_cap cess_rate slabs] - table.keys
|
|
85
|
+
next errors.add(:income_tax, "#{regime} regime is missing #{missing.join(', ')}") if missing.any?
|
|
86
|
+
|
|
87
|
+
# A present-but-empty slab list is the dangerous shape: it validates
|
|
88
|
+
# on key presence and then taxes everybody at zero.
|
|
89
|
+
if Array(table["slabs"]).empty?
|
|
90
|
+
errors.add(:income_tax, "#{regime} regime has no tax slabs — every salary would be taxed at zero")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The one place that answers "may this person do this, and to whose rows".
|
|
3
|
+
#
|
|
4
|
+
# Two questions, deliberately separated:
|
|
5
|
+
#
|
|
6
|
+
# can?(user, "leave.approve") — may they at all, at any scope
|
|
7
|
+
# scope_for(user, "leave.approve") — :self, :team, :all or nil
|
|
8
|
+
# reaches?(user, "leave.approve", other) — may they, for THIS person
|
|
9
|
+
#
|
|
10
|
+
# A controller that only asks the first question is the bug this class
|
|
11
|
+
# exists to prevent: before roles, any admin could approve anybody's leave
|
|
12
|
+
# because nothing ever asked whose leave it was.
|
|
13
|
+
class Access
|
|
14
|
+
# Resolution touches three tables and every screen asks several times per
|
|
15
|
+
# request, so it is memoized per user for the life of the request.
|
|
16
|
+
def self.for(user)
|
|
17
|
+
# An unsaved user holds nothing: roles are rows keyed by user id, and
|
|
18
|
+
# there is no id yet to key them by.
|
|
19
|
+
return new(nil, {}) if user.nil? || user.id.nil?
|
|
20
|
+
|
|
21
|
+
Current.access_cache ||= {}
|
|
22
|
+
Current.access_cache[user.id] ||= new(user, resolve(user))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The strongest scope held for each key across all of the user's roles.
|
|
26
|
+
# Two roles granting the same key keep the WIDER of the two — roles add
|
|
27
|
+
# up, they do not narrow each other.
|
|
28
|
+
def self.resolve(user)
|
|
29
|
+
grants = RoleGrant.joins(role: :role_assignments)
|
|
30
|
+
.where(hr_lite_role_assignments: { user_id: user.id })
|
|
31
|
+
.pluck(:permission_key, :scope)
|
|
32
|
+
|
|
33
|
+
grants.each_with_object({}) do |(key, scope), map|
|
|
34
|
+
held = map[key]
|
|
35
|
+
map[key] = scope if held.nil? || Permissions.scope_covers?(scope, held)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(user, permission_map)
|
|
40
|
+
@user = user
|
|
41
|
+
@map = permission_map
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :user
|
|
45
|
+
|
|
46
|
+
def scope_for(key)
|
|
47
|
+
@map[Permissions.validate!(key)]&.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def can?(key, scope: :self)
|
|
51
|
+
held = scope_for(key)
|
|
52
|
+
held.present? && Permissions.scope_covers?(held, scope)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether this permission reaches a particular person's rows. `self`
|
|
56
|
+
# reaches only the holder; `team` reaches their direct and indirect
|
|
57
|
+
# reports; `all` reaches everyone.
|
|
58
|
+
def reaches?(key, subject_user)
|
|
59
|
+
return false if user.nil? || subject_user.nil?
|
|
60
|
+
|
|
61
|
+
case scope_for(key)
|
|
62
|
+
when :all then true
|
|
63
|
+
when :team then subject_user.id == user.id || report_ids.include?(subject_user.id)
|
|
64
|
+
when :self then subject_user.id == user.id
|
|
65
|
+
else false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# User ids this person's `team` scope covers, for scoping a whole
|
|
70
|
+
# relation rather than checking one row at a time. `all` returns nil,
|
|
71
|
+
# meaning "do not filter" — a caller that treats nil as an empty list
|
|
72
|
+
# would show leadership nothing, so the callers use `scope_relation`.
|
|
73
|
+
def visible_user_ids(key)
|
|
74
|
+
case scope_for(key)
|
|
75
|
+
when :all then nil
|
|
76
|
+
when :team then [ user.id, *report_ids ]
|
|
77
|
+
when :self then [ user.id ]
|
|
78
|
+
else []
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Narrows a relation to the rows this permission reaches. The column is
|
|
83
|
+
# named because not every table calls it user_id.
|
|
84
|
+
def scope_relation(relation, key, column: :user_id)
|
|
85
|
+
ids = visible_user_ids(key)
|
|
86
|
+
ids.nil? ? relation : relation.where(column => ids)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Everyone below this person in the reporting chain. Walked breadth-first
|
|
90
|
+
# with a seen set — EmployeeProfile validates the chain is acyclic, but a
|
|
91
|
+
# cycle written directly to the database must not spin here forever.
|
|
92
|
+
def report_ids
|
|
93
|
+
@report_ids ||= begin
|
|
94
|
+
by_manager = EmployeeProfile.where.not(manager_id: nil).pluck(:manager_id, :user_id)
|
|
95
|
+
.group_by(&:first)
|
|
96
|
+
.transform_values { |pairs| pairs.map(&:last) }
|
|
97
|
+
found = []
|
|
98
|
+
seen = [ user.id ].to_set
|
|
99
|
+
queue = by_manager[user.id]&.dup || []
|
|
100
|
+
|
|
101
|
+
while (id = queue.shift)
|
|
102
|
+
next unless seen.add?(id)
|
|
103
|
+
|
|
104
|
+
found << id
|
|
105
|
+
queue.concat(by_manager[id] || [])
|
|
106
|
+
end
|
|
107
|
+
found
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Calculators
|
|
3
|
-
# State-slab professional tax on earned gross.
|
|
4
|
-
# states
|
|
5
|
-
#
|
|
3
|
+
# State-slab professional tax on earned gross. Slabs may carry a
|
|
4
|
+
# feb_extra for the states that top up the last month's deduction.
|
|
5
|
+
#
|
|
6
|
+
# Slabs come from `hr_lite_professional_tax_slabs`, dated per state,
|
|
7
|
+
# falling back to whatever `rates` hash the caller passes for a host that
|
|
8
|
+
# has not seeded yet. A state nobody configured yields zero — and
|
|
9
|
+
# `unconfigured?` is how payroll gets to SAY so, because a confident zero
|
|
10
|
+
# is the failure mode here, not an exception.
|
|
6
11
|
module ProfessionalTax
|
|
7
|
-
def self.call(state:, gross_earned:, period_month:, rates:)
|
|
8
|
-
slabs =
|
|
12
|
+
def self.call(state:, gross_earned:, period_month:, rates: {})
|
|
13
|
+
slabs = slabs_for(state, period_month, rates)
|
|
9
14
|
earned = Money.d(gross_earned)
|
|
10
15
|
|
|
11
16
|
# `from:` is an INCLUSIVE lower bound — ₹25,000 is taxed, ₹24,999.50 is
|
|
@@ -19,6 +24,30 @@ module HrLite
|
|
|
19
24
|
amount += slab[:feb_extra] if slab[:feb_extra] && period_month.month == 2
|
|
20
25
|
Money.round_rupee(amount)
|
|
21
26
|
end
|
|
27
|
+
|
|
28
|
+
def self.slabs_for(state, period_month, rates = {})
|
|
29
|
+
stored = stored_slabs(state, period_month)
|
|
30
|
+
return stored if stored.present?
|
|
31
|
+
|
|
32
|
+
rates[state.to_s] || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A state with no stored slabs and none in the fallback table. That is
|
|
36
|
+
# NOT the same as a state with no professional tax: Karnataka was the
|
|
37
|
+
# only state ever entered in code, so every other one deducted nothing
|
|
38
|
+
# and nothing said so.
|
|
39
|
+
def self.unconfigured?(state, period_month, rates = {})
|
|
40
|
+
return false if state.to_s == ProfessionalTaxSlab::NO_LEVY
|
|
41
|
+
|
|
42
|
+
slabs_for(state, period_month, rates).empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.stored_slabs(state, period_month)
|
|
46
|
+
ProfessionalTaxSlab.table_for(state, period_month)
|
|
47
|
+
rescue ActiveRecord::ActiveRecordError
|
|
48
|
+
# Host mid-migration: gem upgraded, db:migrate not yet run.
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
22
51
|
end
|
|
23
52
|
end
|
|
24
53
|
end
|
|
@@ -16,10 +16,12 @@ module HrLite
|
|
|
16
16
|
# financial year, every number below it is computed on last year's
|
|
17
17
|
# rates and that is the thing to read first.
|
|
18
18
|
warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
|
|
19
|
+
warnings.concat(Array(StatutoryRateCard.unverified_for(@run.period_month)))
|
|
19
20
|
|
|
20
21
|
ActiveRecord::Base.transaction do
|
|
21
22
|
eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
|
|
22
23
|
keep_ids = []
|
|
24
|
+
unconfigured_states = []
|
|
23
25
|
|
|
24
26
|
eligible.each do |profile|
|
|
25
27
|
user = profile.user
|
|
@@ -29,6 +31,14 @@ module HrLite
|
|
|
29
31
|
next
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Professional tax is a state levy, and a state nobody has entered
|
|
35
|
+
# slabs for deducts nothing — which looks exactly like a state that
|
|
36
|
+
# levies nothing. Collected per state rather than per employee so a
|
|
37
|
+
# forty-person office is one line, not forty.
|
|
38
|
+
if Calculators::ProfessionalTax.unconfigured?(structure.pt_state, @run.period_month)
|
|
39
|
+
unconfigured_states << structure.pt_state
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
slip = @run.salary_slips.find_or_initialize_by(user_id: user.id)
|
|
33
43
|
attributes = SlipBuilder.call(
|
|
34
44
|
run: @run, user: user, structure: structure, profile: profile,
|
|
@@ -44,6 +54,13 @@ module HrLite
|
|
|
44
54
|
keep_ids << slip.id
|
|
45
55
|
end
|
|
46
56
|
|
|
57
|
+
unconfigured_states.uniq.sort.each do |state|
|
|
58
|
+
warnings << "No professional-tax slabs are configured for " \
|
|
59
|
+
"#{state.to_s.humanize} — everybody there is being deducted ₹0. " \
|
|
60
|
+
"Enter that state's bands, or set their structures to 'none' if " \
|
|
61
|
+
"it genuinely levies no PT."
|
|
62
|
+
end
|
|
63
|
+
|
|
47
64
|
@run.salary_slips.where.not(id: keep_ids).destroy_all
|
|
48
65
|
@run.update!(warnings: warnings)
|
|
49
66
|
end
|