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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
  5. data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
  6. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
  7. data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
  8. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
  9. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  10. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  11. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  12. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +97 -0
  13. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  14. data/app/controllers/hr_lite/application_controller.rb +39 -1
  15. data/app/helpers/hr_lite/application_helper.rb +6 -2
  16. data/app/models/hr_lite/professional_tax_slab.rb +41 -0
  17. data/app/models/hr_lite/role.rb +66 -0
  18. data/app/models/hr_lite/role_assignment.rb +18 -0
  19. data/app/models/hr_lite/role_grant.rb +15 -0
  20. data/app/models/hr_lite/statutory_rate_card_record.rb +95 -0
  21. data/app/services/hr_lite/access.rb +111 -0
  22. data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
  23. data/app/services/hr_lite/payroll_run_processor.rb +17 -0
  24. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  25. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  26. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  27. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  28. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
  29. data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
  30. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
  31. data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
  32. data/config/routes.rb +4 -0
  33. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  34. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  35. data/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
  36. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  37. data/lib/hr_lite/configuration.rb +8 -1
  38. data/lib/hr_lite/current.rb +4 -0
  39. data/lib/hr_lite/permissions.rb +79 -0
  40. data/lib/hr_lite/role_seeds.rb +81 -0
  41. data/lib/hr_lite/statutory_rate_card.rb +55 -10
  42. data/lib/hr_lite/statutory_seeds.rb +71 -0
  43. data/lib/hr_lite/version.rb +1 -1
  44. data/lib/hr_lite.rb +64 -10
  45. data/lib/tasks/hr_lite_tasks.rake +18 -2
  46. 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 tier: salary structures, payroll, slips, appraisals and
4
- # promotions. Only config.superadmin_emailsordinary leadership can
5
- # govern policy and people but never sees another person's pay.
6
- # The money tier stands on its own rather than layering on leadership: a
7
- # payroll operator does not have to also govern people and policy.
8
- # Inheriting the leadership check locked a configured superadmin who was
9
- # absent from leadership_emails out of payroll entirely.
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 :require_hr_leadership!
12
- before_action :require_hr_superadmin!
12
+ skip_before_action :require_governing_access!
13
+ before_action :require_money_access!
13
14
 
14
15
  private
15
16
 
16
- def require_hr_superadmin!
17
- hr_access_denied unless hr_superadmin?
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 tier only config.superadmin_emails see these.
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. Unknown states and
4
- # states without PT (UP, Uttarakhand) simply yield zero. Slabs may
5
- # carry a feb_extra for states that top up the February deduction.
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 = rates[state.to_s] || []
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