hr_lite 0.6.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7dd5723f67a89de78893c7ef752581da400b8703fb60876ad7d283e280308d31
4
- data.tar.gz: d8e517a4a80bb2026de50a9f30b7837b37b785d02a5aea79802ad72b78fcc630
3
+ metadata.gz: 7ddcf4d252c3b0830d1b345dd44f63dc997cab9db77a408249cd8940ad2256c1
4
+ data.tar.gz: d525ae88d6a0b5c12ca68caff325bb655cd2ff7dfa0ac9afb7f178cbc8d362de
5
5
  SHA512:
6
- metadata.gz: ea7291efd85233dfabf368fdc2355f8050920a22e324ea9f0ac083ba10040aaacaf125f93ed2d89b94e3ab05ed496375d69a6499af66372a974498677a30a18b
7
- data.tar.gz: 1c1ad921b96ae6d778711b61b62501b5a8fa2e6cfd08cb8fd2c162bc01d37c7f048bc9637a617f1eebcac2f51a01e669cff89d94adf3bd48724b7cb9c7959a05
6
+ metadata.gz: 155c04f716bbf5907b17ce1dd86c06f44de4f3f8021afda1e2ed3570db8cf14aed2373db7791d4c65441e96b6d4e0318faeb8dc1116b35bea235ed4cb31775d8
7
+ data.tar.gz: be25c7f77087ae53dccb3e991167cc78282de562c5ef9f12739db9bc2bece99ad8b1e743577dc21d3aeb935cd1ca270ae64652bd036459c8c704b438db1ed8c0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-08-18
11
+
12
+ The deprecation 0.6.0 opened, closed. **Breaking** only for an install still
13
+ setting `config.legacy_tier_checks`; no migration.
14
+
15
+ ### Removed
16
+
17
+ - **`config.legacy_tier_checks` and the pre-0.6.0 tier lambdas no longer
18
+ decide access.** `HrLite.admin?`, `.leadership?` and `.superadmin?` read
19
+ roles, full stop. Two of those lambdas matched the user's `email` — mutable,
20
+ host-owned, unverified — and a host that kept the flag on kept that hole
21
+ open.
22
+ - The flag is gone from the configuration object entirely, so an initializer
23
+ that still sets it now fails loudly at boot rather than silently doing
24
+ nothing. That is the intended way to find out.
25
+
26
+ ### Changed
27
+
28
+ - `admin_check`, `leadership_emails` / `leadership_check` and
29
+ `superadmin_emails` / `superadmin_check` remain on the configuration, and
30
+ are now **migration input only**: the 0.6.0 upgrade migration reads them to
31
+ derive role assignments. An install jumping 0.5.x → 0.8.0 in one step still
32
+ needs them present for that migration to have something to read, which is
33
+ why they were not deleted outright. Setting them on a current install has no
34
+ effect on who can reach what.
35
+ - `HrLite.leadership_users` and `.admin_users` are one query over the grant
36
+ tables. The legacy paths that instantiated every employee and ran a host
37
+ lambda per row are gone.
38
+
39
+ ### Upgrading
40
+
41
+ If you set `legacy_tier_checks = true`, delete that line. Before you do, open
42
+ the Roles screen and confirm the people who need access hold it — the 0.6.0
43
+ migration will have assigned them already unless you had turned the flag on
44
+ before it ran.
45
+
46
+ ## [0.7.0] - 2026-08-18
47
+
48
+ Statutory figures stop being code. **Adds one migration** (rate cards and
49
+ professional-tax slabs) and seeds it from the figures the gem already
50
+ shipped — no number changes on upgrade.
51
+
52
+ ### Fixed
53
+
54
+ - **Every state except Karnataka deducted ₹0 professional tax, silently.** PT
55
+ is a state levy and only Karnataka had bands in code; every other state
56
+ resolved to an empty slab list and deducted nothing, which is a wrong answer
57
+ delivered confidently. Slabs are now per state and effective-dated, and a
58
+ run whose employees sit in a state nobody has configured says so — once per
59
+ state, not once per employee.
60
+ - **Adding a financial year needed a gem release.** Every April was a deadline
61
+ the gem controlled and the company did not; 0.5.3 shipped a warning about
62
+ exactly that. Cards are rows now, entered on a screen.
63
+ - A tax regime saved with an empty slab list validated on key presence and
64
+ then taxed every salary at zero. Refused.
65
+
66
+ ### Added
67
+
68
+ - `hr_lite_statutory_rate_cards` — PF, ESI and both regimes' slabs per
69
+ financial year, effective-dated. A card must start on 1 April (a mid-year
70
+ card would mean two slab sets inside one year, and the TDS projector works
71
+ on an annual figure), and must carry every figure payroll reads — a card
72
+ missing one fails at save time rather than half way through computing
73
+ somebody's salary.
74
+ - `hr_lite_professional_tax_slabs` — per state, per date, with the February
75
+ top-up the states that levy one need.
76
+ - A rate-card screen (money tier). Adding a year copies the newest card, since
77
+ most years carry PF and ESI over untouched and move only the slabs — a blank
78
+ form would invite retyping eight numbers that were already right.
79
+ - **Sign-off.** A card records who checked it and when, and payroll warns on
80
+ every run computed against one nobody has confirmed. Ranked below the
81
+ wrong-financial-year warning: bad figures matter more than missing paperwork.
82
+ - `rake hr_lite:statutory`, and `hr_lite:seed` copies the shipped figures in.
83
+
84
+ ### Changed
85
+
86
+ - `StatutoryRateCard.for` reads the table, falling back to the shipped hash
87
+ for a host that has upgraded the gem but not yet run `db:migrate` — payroll
88
+ must not 500 on a missing table.
89
+ - Branch-coverage floor 90.5% -> 90.8%.
90
+
91
+ ### Deferred
92
+
93
+ - **`config.legacy_tier_checks` and the pre-0.6.0 tier lambdas are still
94
+ here.** 0.6.0 said they would go in this release. They have not: removing an
95
+ access mechanism in the same release that rewrites where statutory figures
96
+ live puts two unrelated upgrade risks in one step, and this release is
97
+ otherwise purely additive. They go in **0.8.0**, on their own. If you are
98
+ still relying on the flag, the 0.6.0 migration has already created the
99
+ equivalent role assignments — turn it off and check the Roles screen.
100
+
10
101
  ## [0.6.0] - 2026-08-18
11
102
 
12
103
  Access stops being three lambdas and becomes a role table. **Breaking**, with
@@ -458,7 +549,9 @@ Initial release.
458
549
  bus with per-event channel matrix (bell, email, leadership email/bell),
459
550
  daily leadership digest and an append-only audit trail.
460
551
 
461
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...HEAD
552
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...HEAD
553
+ [0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
554
+ [0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
462
555
  [0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
463
556
  [0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
464
557
  [0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
data/README.md CHANGED
@@ -210,8 +210,9 @@ where they already were: `superadmin_emails` to Super Admin,
210
210
  It prints who it put where, and it leaves a host that has already assigned
211
211
  roles by hand alone.
212
212
 
213
- Set `config.legacy_tier_checks = true` to keep the old lambdas in charge while
214
- you migrate. It is honoured for one minor version and removed in 0.7.0.
213
+ As of 0.8.0 those lambdas decide nothing. They are read only by that
214
+ migration, so an install jumping several versions at once still has something
215
+ for it to derive from; setting them on a current install has no effect.
215
216
 
216
217
  ## Features
217
218
 
@@ -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
@@ -29,7 +29,8 @@ module HrLite
29
29
  # other governing screens.
30
30
  SUPERADMIN_NAV_ITEMS = [
31
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" ] }
32
+ { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] },
33
+ { label: "Rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ] }
33
34
  ].freeze
34
35
 
35
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,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
@@ -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
@@ -0,0 +1,109 @@
1
+ <section class="hrl-card">
2
+ <% if local_assigns[:copied_from] %>
3
+ <p class="hrl-muted hrl-small">
4
+ Copied from FY <%= copied_from.financial_year %>. Most years carry PF and
5
+ ESI over untouched and move only the slabs — change what the Finance Act
6
+ changed, and leave the rest.
7
+ </p>
8
+ <% end %>
9
+
10
+ <%= form_with model: card, url: url, method: method, local: true,
11
+ scope: :statutory_rate_card do |f| %>
12
+ <%= render "hr_lite/shared/form_errors", record: card %>
13
+
14
+ <div class="hrl-field">
15
+ <%= f.label :effective_from, "Financial year starts (1 April)" %>
16
+ <%= f.date_field :effective_from %>
17
+ </div>
18
+
19
+ <fieldset>
20
+ <legend>Provident fund</legend>
21
+ <% { employee_rate: "Employee rate (0.12 = 12%)", employer_rate: "Employer rate",
22
+ eps_rate: "EPS rate", wage_ceiling: "PF wage ceiling (₹)",
23
+ eps_wage_ceiling: "EPS wage ceiling (₹)", edli_rate: "EDLI rate",
24
+ edli_ceiling: "EDLI ceiling (₹)", admin_rate: "PF admin rate" }.each do |key, label| %>
25
+ <div class="hrl-field">
26
+ <%= label_tag "statutory_rate_card_pf_#{key}", label %>
27
+ <%= text_field_tag "statutory_rate_card[pf][#{key}]", card.pf[key.to_s],
28
+ id: "statutory_rate_card_pf_#{key}", inputmode: "decimal" %>
29
+ </div>
30
+ <% end %>
31
+ </fieldset>
32
+
33
+ <fieldset>
34
+ <legend>ESI</legend>
35
+ <% { employee_rate: "Employee rate", employer_rate: "Employer rate",
36
+ gross_ceiling: "Gross ceiling (₹)" }.each do |key, label| %>
37
+ <div class="hrl-field">
38
+ <%= label_tag "statutory_rate_card_esi_#{key}", label %>
39
+ <%= text_field_tag "statutory_rate_card[esi][#{key}]", card.esi[key.to_s],
40
+ id: "statutory_rate_card_esi_#{key}", inputmode: "decimal" %>
41
+ </div>
42
+ <% end %>
43
+ </fieldset>
44
+
45
+ <% %w[new old].each do |regime| %>
46
+ <% table = card.income_tax[regime] || {} %>
47
+ <fieldset>
48
+ <legend><%= regime.capitalize %> regime</legend>
49
+ <% { "standard_deduction" => "Standard deduction (₹)",
50
+ "rebate_cap" => "§87A rebate cap (₹)",
51
+ "cess_rate" => "Cess rate (0.04 = 4%)" }.each do |key, label| %>
52
+ <div class="hrl-field">
53
+ <%= label_tag "statutory_rate_card_it_#{regime}_#{key}", label %>
54
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][#{key}]", table[key],
55
+ id: "statutory_rate_card_it_#{regime}_#{key}", inputmode: "decimal" %>
56
+ </div>
57
+ <% end %>
58
+ <div class="hrl-field hrl-field--check">
59
+ <%= hidden_field_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "0" %>
60
+ <%= check_box_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "1",
61
+ table["marginal_relief"] %>
62
+ <%= label_tag "statutory_rate_card_income_tax_#{regime}_marginal_relief",
63
+ "Marginal relief just above the rebate cap" %>
64
+ </div>
65
+
66
+ <p class="hrl-small hrl-muted">
67
+ Slabs, lowest first. Leave the last band's "up to" empty — that is
68
+ the open-ended top rate.
69
+ </p>
70
+ <% slabs = Array(table["slabs"]) %>
71
+ <% (slabs + [ nil, nil ]).each_with_index do |row, index| %>
72
+ <div class="hrl-row">
73
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][from]",
74
+ row&.first, placeholder: "From ₹", inputmode: "decimal" %>
75
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][to]",
76
+ row&.at(1), placeholder: "Up to ₹ (blank = top)", inputmode: "decimal" %>
77
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][rate]",
78
+ row&.last, placeholder: "Rate (0.05)", inputmode: "decimal" %>
79
+ </div>
80
+ <% end %>
81
+ </fieldset>
82
+ <% end %>
83
+
84
+ <fieldset>
85
+ <legend>Sign-off</legend>
86
+ <p class="hrl-small hrl-muted">
87
+ Until a card is signed off, every payroll run computed on it carries a
88
+ warning. Record the person who actually checked the figures.
89
+ </p>
90
+ <div class="hrl-field">
91
+ <%= f.label :verified_by, "Checked by (name)" %>
92
+ <%= f.text_field :verified_by %>
93
+ </div>
94
+ <div class="hrl-field">
95
+ <%= f.label :verified_on, "Checked on" %>
96
+ <%= f.date_field :verified_on %>
97
+ </div>
98
+ <div class="hrl-field">
99
+ <%= f.label :notes, "Notes" %>
100
+ <%= f.text_field :notes %>
101
+ </div>
102
+ </fieldset>
103
+
104
+ <div class="hrl-form-actions">
105
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
106
+ <a class="hrl-btn" href="<%= hr_lite.admin_statutory_rate_cards_path %>">Cancel</a>
107
+ </div>
108
+ <% end %>
109
+ </section>
@@ -0,0 +1,4 @@
1
+ <% content_for(:page_title) { "FY #{@card.financial_year}" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">FY <%= @card.financial_year %></h1></div>
3
+ <%= render "form", card: @card,
4
+ url: hr_lite.admin_statutory_rate_card_path(@card), method: :patch %>
@@ -0,0 +1,59 @@
1
+ <% content_for(:page_title) { "Statutory rates" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Statutory rates</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_statutory_rate_card_path %>">Add a year</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <p class="hrl-muted hrl-small">
11
+ PF, ESI and the income-tax slabs, per financial year. Payroll resolves the
12
+ card that was in force for the month it is computing, so correcting a year
13
+ never changes an earlier one.
14
+ </p>
15
+ <div class="hrl-table-wrap">
16
+ <table class="hrl-table hrl-table--stack">
17
+ <thead><tr><th>Year</th><th>PF ceiling</th><th>ESI ceiling</th><th>Verified</th><th></th></tr></thead>
18
+ <tbody>
19
+ <% @cards.each do |card| %>
20
+ <tr>
21
+ <td data-label="Year">
22
+ <strong>FY <%= card.financial_year %></strong>
23
+ <div class="hrl-small hrl-muted">from <%= card.effective_from.strftime("%d %b %Y") %></div>
24
+ </td>
25
+ <td data-label="PF ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:pf][:wage_ceiling]) %></td>
26
+ <td data-label="ESI ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:esi][:gross_ceiling]) %></td>
27
+ <td data-label="Verified">
28
+ <% if card.verified? %>
29
+ <span class="hrl-badge"><%= card.verified_by %></span>
30
+ <div class="hrl-small hrl-muted"><%= card.verified_on.strftime("%d %b %Y") %></div>
31
+ <% else %>
32
+ <span class="hrl-badge hrl-badge--muted">Not checked</span>
33
+ <% end %>
34
+ </td>
35
+ <td data-label="">
36
+ <a class="hrl-small" href="<%= hr_lite.edit_admin_statutory_rate_card_path(card) %>">Edit</a>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </section>
44
+
45
+ <section class="hrl-card">
46
+ <h2 class="hrl-card__title">Professional tax</h2>
47
+ <% if @states.empty? %>
48
+ <p class="hrl-muted">
49
+ No state bands are configured. Everybody's professional tax is being
50
+ deducted at ₹0 — which is right only if no state you employ in levies it.
51
+ </p>
52
+ <% else %>
53
+ <p class="hrl-muted hrl-small">
54
+ Bands are configured for: <%= @states.map(&:humanize).to_sentence %>.
55
+ An employee whose salary structure names any other state is deducted ₹0,
56
+ and payroll says so on the run.
57
+ </p>
58
+ <% end %>
59
+ </section>
@@ -0,0 +1,4 @@
1
+ <% content_for(:page_title) { "Add a financial year" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Add a financial year</h1></div>
3
+ <%= render "form", card: @card, copied_from: @copied_from,
4
+ url: hr_lite.admin_statutory_rate_cards_path, method: :post %>
data/config/routes.rb CHANGED
@@ -49,6 +49,7 @@ HrLite::Engine.routes.draw do
49
49
  resources :roles, except: :show do
50
50
  resources :assignments, only: %i[create destroy], controller: "role_assignments"
51
51
  end
52
+ resources :statutory_rate_cards, only: %i[index new create edit update]
52
53
  resources :leave_types, except: :show
53
54
  resources :office_locations, except: :show
54
55
  resources :holidays, only: %i[index create update destroy] do
@@ -0,0 +1,44 @@
1
+ class CreateHrLiteStatutoryRateCards < ActiveRecord::Migration[8.1]
2
+ # Statutory rates lived in a frozen hash in Ruby, so adding a financial year
3
+ # meant a gem release. Every April that is a deadline the gem controls and
4
+ # the company does not — and 0.5.3 shipped a warning about exactly that
5
+ # because the FY2026-27 card did not exist.
6
+ #
7
+ # Rates are not secret (they are published by the government), so the
8
+ # figures are plain JSON. What matters is that they are EFFECTIVE-DATED and
9
+ # carry who checked them: a payroll rerun of an old month must resolve to
10
+ # the card that was in force for that month, not to today's.
11
+ def change
12
+ create_table :hr_lite_statutory_rate_cards do |t|
13
+ t.date :effective_from, null: false
14
+ t.json :pf, null: false, default: {}
15
+ t.json :esi, null: false, default: {}
16
+ t.json :income_tax, null: false, default: {}
17
+ # Who signed it off, and when. A card nobody has confirmed is a card
18
+ # payroll should say something about — see SlipBuilder's warnings.
19
+ t.string :verified_by
20
+ t.date :verified_on
21
+ t.text :notes
22
+ t.timestamps
23
+ end
24
+ # One card per date: two would make "which rates applied in June" a
25
+ # question about row order.
26
+ add_index :hr_lite_statutory_rate_cards, :effective_from, unique: true
27
+
28
+ create_table :hr_lite_professional_tax_slabs do |t|
29
+ # Professional tax is a STATE levy. Karnataka was the only state with
30
+ # real slabs in code; every other state silently computed zero, which
31
+ # is a wrong answer delivered confidently.
32
+ t.string :state, null: false
33
+ t.date :effective_from, null: false
34
+ # Inclusive lower bound on monthly earned gross.
35
+ t.decimal :from_amount, precision: 12, scale: 2, null: false
36
+ t.decimal :monthly, precision: 12, scale: 2, null: false
37
+ # Several states collect a larger amount in the last month of the year.
38
+ t.decimal :feb_extra, precision: 12, scale: 2
39
+ t.timestamps
40
+ end
41
+ add_index :hr_lite_professional_tax_slabs, %i[state effective_from from_amount],
42
+ unique: true, name: "index_hr_lite_pt_slabs_on_state_date_and_bound"
43
+ end
44
+ end
@@ -15,11 +15,6 @@ HrLite.configure do |c|
15
15
  # HrLite::RoleAssignment.create!(
16
16
  # user_id: User.find_by!(email: "you@example.com").id,
17
17
  # role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
18
- #
19
- # Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
20
- # your leadership_emails / superadmin_emails / admin_check as they were.
21
- # It is honoured until 0.7.0.
22
- # c.legacy_tier_checks = true
23
18
 
24
19
  # Where the portal is reachable (subdomain or path). Enables email link
25
20
  # buttons and HrLite.public_url / HrLite.public_url? for deep links.
@@ -7,11 +7,9 @@ module HrLite
7
7
  :authenticate_method, :admin_check, :display_name_method,
8
8
  :employees_scope, :mentionable_users, :notify, :render_pdf, :company,
9
9
  :time_zone, :currency_symbol, :on_designation_change,
10
- :leadership_emails, :leadership_check, :extra_stylesheets,
11
- :superadmin_emails, :superadmin_check,
10
+ :leadership_emails, :extra_stylesheets, :superadmin_emails,
12
11
  :mailer_from, :public_url_base, :notification_matrix, :back_link,
13
- :onboard_user, :offboard_user, :invite_url_for,
14
- :legacy_tier_checks
12
+ :onboard_user, :offboard_user, :invite_url_for
15
13
 
16
14
  attr_reader :leave_year_start_month
17
15
 
@@ -46,21 +44,20 @@ module HrLite
46
44
  @time_zone = "Asia/Kolkata"
47
45
  @currency_symbol = "₹"
48
46
  @on_designation_change = ->(user, designation) { }
49
- # Access came from three lambdas before 0.6.0, two of which matched the
50
- # user's EMAIL — a mutable, host-owned, unverified column that a host
51
- # then had to remember never to let anyone edit. Roles replace them.
52
- # This flag hands authority back to the old lambdas for a host that has
53
- # not finished migrating; honoured for one minor version, gone in 0.7.0.
54
- @legacy_tier_checks = false
47
+ # --- pre-0.6.0 access, now MIGRATION INPUT ONLY ------------------------
48
+ #
49
+ # These three governed access until 0.6.0, and two of them matched the
50
+ # user's EMAIL a mutable, host-owned, unverified column that every
51
+ # host then had to remember never to let anybody edit. They decide
52
+ # nothing now: `HrLite.admin?` and friends read roles.
53
+ #
54
+ # They are still read in exactly one place — the 0.6.0 upgrade
55
+ # migration, which derives role assignments from whatever a 0.5.x host
56
+ # had configured. An install upgrading across several versions at once
57
+ # needs them present for that migration to have anything to read, which
58
+ # is why they were not deleted outright.
55
59
  @leadership_emails = []
56
- @leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
57
- # Money tier: salary structures, payroll, slips, appraisals. Empty
58
- # list means "same as leadership" (pre-0.5.0 behaviour).
59
60
  @superadmin_emails = []
60
- @superadmin_check = ->(user) do
61
- list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
62
- list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
63
- end
64
61
  @extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
65
62
  @mailer_from = "hr@example.com"
66
63
  @public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
@@ -1,15 +1,19 @@
1
1
  module HrLite
2
- # Config-driven statutory rates, keyed by effective date a budget change
3
- # is a one-hash edit that gets code review and a spec diff, never an
4
- # inline-constant hunt.
2
+ # Where payroll asks "what were the statutory figures in this month".
5
3
  #
6
- # VERIFY WITH A CA before the first run of any new financial year. Adding
7
- # a year is one new dated entry in CARDS; nothing else changes.
4
+ # Cards live in the DATABASE (`hr_lite_statutory_rate_cards`), effective-
5
+ # dated, so adding a financial year is a screen an accountant fills in
6
+ # rather than a gem release. Every April used to be a deadline the gem
7
+ # controlled and the company did not.
8
8
  #
9
- # When no card exists for a run's financial year the lookup falls back to
10
- # the newest one it has payroll cannot simply stop every April — but
11
- # `warning_for` says so on the run, and `PayrollRunProcessor` puts that
12
- # sentence in front of every other warning until a card is added.
9
+ # CARDS below is the SEED the figures the gem ships with, copied into the
10
+ # table by `hr_lite:seed` and then owned by the install. It is also the
11
+ # fallback for a host that has not migrated or seeded yet, so the lookup
12
+ # never returns nil half-way through computing somebody's salary.
13
+ #
14
+ # When no card covers a run's financial year the lookup still answers —
15
+ # payroll cannot stop dead every 1 April — but `warning_for` says so, and
16
+ # `PayrollRunProcessor` puts that sentence above every other warning.
13
17
  module StatutoryRateCard
14
18
  def self.r(value) = BigDecimal(value.to_s)
15
19
  private_class_method :r
@@ -62,15 +66,44 @@ module HrLite
62
66
  }.freeze
63
67
 
64
68
  def self.for(period_month)
69
+ record = record_for(period_month)
70
+ return record.to_card if record
71
+
65
72
  CARDS[effective_date_for(period_month)]
66
73
  end
67
74
 
75
+ # The stored card a run resolves to, or nil when the table is empty (a
76
+ # host that has not run `hr_lite:seed` yet) and the shipped hash answers
77
+ # instead.
78
+ def self.record_for(period_month)
79
+ return nil unless stored?
80
+
81
+ StatutoryRateCardRecord.effective_for(period_month)
82
+ end
83
+
84
+ # Deliberately rescued: the lookup is called from payroll, and a host
85
+ # mid-migration — gem upgraded, `db:migrate` not yet run — must fall back
86
+ # to the shipped figures rather than 500.
87
+ def self.stored?
88
+ StatutoryRateCardRecord.table_exists? && StatutoryRateCardRecord.exists?
89
+ rescue ActiveRecord::ActiveRecordError
90
+ false
91
+ end
92
+
68
93
  # Which card a run will actually use. A month older than every card
69
94
  # borrows the earliest one — see `predates_cards?`, which says so.
70
95
  def self.effective_date_for(period_month)
96
+ if stored?
97
+ return StatutoryRateCardRecord.effective_for(period_month).effective_from
98
+ end
99
+
71
100
  CARDS.keys.sort.reverse.find { |date| date <= period_month } || CARDS.keys.min
72
101
  end
73
102
 
103
+ def self.earliest_date
104
+ stored? ? StatutoryRateCardRecord.chronological.first.effective_from : CARDS.keys.min
105
+ end
106
+
74
107
  # The card in force is from an EARLIER financial year than the run. The
75
108
  # lookup still returns figures — it has to, or payroll would stop dead
76
109
  # every April — so the run carries the warning instead.
@@ -81,7 +114,19 @@ module HrLite
81
114
  # The run is older than every card we ship, so it is being computed on
82
115
  # rates that had not been announced yet.
83
116
  def self.predates_cards?(period_month)
84
- period_month < CARDS.keys.min
117
+ period_month < earliest_date
118
+ end
119
+
120
+ # A card exists for the run's year but nobody has signed it off. Ranked
121
+ # below staleness because wrong-year figures are the worse problem, but
122
+ # still worth saying: these numbers decide what lands in a bank account.
123
+ def self.unverified_for(period_month)
124
+ record = record_for(period_month)
125
+ return nil if record.nil? || record.verified? || stale_for?(period_month)
126
+
127
+ "The FY #{record.financial_year} statutory card has not been marked " \
128
+ "verified. Check PF, ESI and the tax slabs with your accountant, then " \
129
+ "record who confirmed them on the rate-card screen."
85
130
  end
86
131
 
87
132
  # One sentence naming both the run's FY and the card's, or nil when they
@@ -0,0 +1,71 @@
1
+ module HrLite
2
+ # Copies the figures the gem ships with into the tables that own them from
3
+ # then on. Idempotent, and it NEVER touches a card the install already has:
4
+ # once an accountant has corrected a number, a deploy must not put the
5
+ # gem's version back.
6
+ #
7
+ # Nothing here is invented. Every figure is lifted from
8
+ # StatutoryRateCard::CARDS, which is the same hash that has always shipped.
9
+ module StatutorySeeds
10
+ def self.call
11
+ seed_cards! + seed_pt_slabs!
12
+ end
13
+
14
+ def self.seed_cards!
15
+ StatutoryRateCard::CARDS.filter_map do |effective_from, card|
16
+ next if StatutoryRateCardRecord.exists?(effective_from: effective_from)
17
+
18
+ StatutoryRateCardRecord.create!(
19
+ effective_from: effective_from,
20
+ pf: stringify(card[:pf]),
21
+ esi: stringify(card[:esi]),
22
+ income_tax: card[:income_tax].transform_values { |regime| stringify_regime(regime) },
23
+ notes: "Shipped with hr_lite #{HrLite::VERSION}. Confirm with your " \
24
+ "accountant, then record who verified it."
25
+ )
26
+ "rate card FY #{FinancialYear.label(effective_from)}"
27
+ end
28
+ end
29
+
30
+ def self.seed_pt_slabs!
31
+ created = []
32
+ StatutoryRateCard::CARDS.each do |effective_from, card|
33
+ card[:pt].each do |state, slabs|
34
+ # An empty slab list in the shipped hash meant "we know of no bands
35
+ # for this state" — which is exactly the silence the slab table is
36
+ # here to distinguish from a real zero. Nothing to copy.
37
+ next if slabs.empty?
38
+ next if ProfessionalTaxSlab.exists?(state: state, effective_from: effective_from)
39
+
40
+ slabs.each do |slab|
41
+ ProfessionalTaxSlab.create!(
42
+ state: state, effective_from: effective_from,
43
+ from_amount: slab[:from] || slab[:above],
44
+ monthly: slab[:monthly], feb_extra: slab[:feb_extra]
45
+ )
46
+ end
47
+ created << "#{state} PT slabs"
48
+ end
49
+ end
50
+ created
51
+ end
52
+
53
+ # JSON columns take strings so a BigDecimal survives the round trip
54
+ # intact — a float would not, and these figures decide deductions.
55
+ def self.stringify(hash)
56
+ hash.to_h { |key, value| [ key.to_s, value.to_s("F") ] }
57
+ end
58
+
59
+ def self.stringify_regime(regime)
60
+ {
61
+ "standard_deduction" => regime[:standard_deduction].to_s("F"),
62
+ "rebate_cap" => regime[:rebate_cap].to_s("F"),
63
+ "cess_rate" => regime[:cess_rate].to_s("F"),
64
+ "marginal_relief" => !!regime[:marginal_relief],
65
+ "slabs" => regime[:slabs].map { |from, to, rate|
66
+ [ from.to_s("F"), to&.to_s("F"), rate.to_s("F") ]
67
+ }
68
+ }
69
+ end
70
+ end
71
+ end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -6,6 +6,7 @@ require "hr_lite/leave_year"
6
6
  require "hr_lite/financial_year"
7
7
  require "hr_lite/permissions"
8
8
  require "hr_lite/role_seeds"
9
+ require "hr_lite/statutory_seeds"
9
10
  require "hr_lite/mention_parser"
10
11
  require "hr_lite/notifications"
11
12
  require "hr_lite/seeds"
@@ -50,62 +51,43 @@ module HrLite
50
51
 
51
52
  def access_for(user) = Access.for(user)
52
53
 
53
- # --- legacy tier predicates --------------------------------------------
54
+ # --- tier predicates ----------------------------------------------------
54
55
  #
55
- # Kept because views, hosts and the notification fan-out all call them.
56
- # They are now READ OFF ROLES rather than off a mutable email column; the
57
- # configured lambdas survive only as an explicit opt-in for a host that
58
- # has not migrated yet (see Configuration#legacy_tier_checks).
56
+ # Shorthands over the permissions, kept because views, hosts and the
57
+ # notification fan-out all read better asking "is this person leadership"
58
+ # than "do they hold profile.manage at all scope". They are ROLES all the
59
+ # way down; the pre-0.6.0 lambdas no longer decide anything.
59
60
 
60
61
  def admin?(user)
61
62
  return false if user.blank?
62
- return !!config.admin_check.call(user) if config.legacy_tier_checks
63
63
 
64
64
  can?(user, "leave.approve", scope: :all) || can?(user, "attendance.manage", scope: :all)
65
65
  end
66
66
 
67
67
  def leadership?(user)
68
68
  return false if user.blank?
69
- return !!config.leadership_check.call(user) if config.legacy_tier_checks
70
69
 
71
70
  can?(user, "profile.manage", scope: :all)
72
71
  end
73
72
 
74
73
  def superadmin?(user)
75
74
  return false if user.blank?
76
- return !!config.superadmin_check.call(user) if config.legacy_tier_checks
77
75
 
78
76
  can?(user, "payroll.manage", scope: :all)
79
77
  end
80
78
 
81
- # An access list, cleaned. "a@x.com,,b@x.com".split(",") one stray
82
- # comma in an ENV var used to put "" in the list, and a user whose
83
- # email was blank then MATCHED it and was handed the tier.
79
+ # An access list, cleaned — read now only by the 0.6.0 upgrade migration.
80
+ # One stray comma in an ENV var ("a@x.com,,b@x.com") used to put "" in the
81
+ # list, and a user whose email was blank then MATCHED it and was handed
82
+ # the tier. Kept honest here so the migration cannot repeat that.
84
83
  def normalize_email_list(emails)
85
84
  Array(emails).map { |e| e.to_s.downcase.strip }.reject(&:empty?)
86
85
  end
87
86
 
88
- # Whether a user's own email is on a configured access list. A blank
89
- # email is never on any list, however the list is spelled.
90
- def email_listed?(user, emails)
91
- address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
92
- return false if address.empty?
93
-
94
- normalize_email_list(emails).include?(address)
95
- end
96
-
97
87
  # Leadership members resolvable to actual user records (for bell
98
- # notifications). On a legacy host, emails configured but absent from the
99
- # user table are still reachable by email — see Notifications.
88
+ # notifications).
100
89
  def leadership_users
101
- unless config.legacy_tier_checks
102
- return users_holding("profile.manage", scope: :all)
103
- end
104
-
105
- emails = normalize_email_list(config.leadership_emails)
106
- return user_klass.none if emails.empty?
107
-
108
- user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
90
+ users_holding("profile.manage", scope: :all)
109
91
  end
110
92
 
111
93
  # Everyone whose roles grant `key` at `scope` or wider. One query over
@@ -120,13 +102,9 @@ module HrLite
120
102
  user_klass.where(id: ids)
121
103
  end
122
104
 
123
- # Every domain event that bells the admins calls this. On roles it is one
124
- # query over the grant tables; on a legacy host it still has to run the
125
- # host's lambda per row, which is why it starts from employees_scope
126
- # (narrowed to real staff) rather than every row that exists.
105
+ # Every domain event that bells the admins calls this. One query over the
106
+ # grant tables it used to instantiate every employee and ask each one.
127
107
  def admin_users
128
- return employees.select { |u| admin?(u) } if config.legacy_tier_checks
129
-
130
108
  users_holding("leave.approve", scope: :all).sort_by { |u| display_name(u).downcase }
131
109
  end
132
110
 
@@ -3,10 +3,18 @@ namespace :hr_lite do
3
3
  task seed: :environment do
4
4
  require "hr_lite/seeds"
5
5
  require "hr_lite/role_seeds"
6
- created = HrLite::Seeds.run! + HrLite::RoleSeeds.call
6
+ require "hr_lite/statutory_seeds"
7
+ created = HrLite::Seeds.run! + HrLite::RoleSeeds.call + HrLite::StatutorySeeds.call
7
8
  puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
8
9
  end
9
10
 
11
+ desc "Copy the shipped statutory figures into the rate-card tables (never overwrites)"
12
+ task statutory: :environment do
13
+ require "hr_lite/statutory_seeds"
14
+ created = HrLite::StatutorySeeds.call
15
+ puts created.any? ? "hr_lite:statutory created: #{created.join(', ')}" : "hr_lite:statutory — nothing to do"
16
+ end
17
+
10
18
  desc "Idempotently seed the built-in roles only (never overwrites a tuned role)"
11
19
  task roles: :environment do
12
20
  require "hr_lite/role_seeds"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -77,6 +77,7 @@ files:
77
77
  - app/controllers/hr_lite/admin/salary_slips_controller.rb
78
78
  - app/controllers/hr_lite/admin/salary_structures_controller.rb
79
79
  - app/controllers/hr_lite/admin/settings_controller.rb
80
+ - app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb
80
81
  - app/controllers/hr_lite/admin/superadmin_controller.rb
81
82
  - app/controllers/hr_lite/application_controller.rb
82
83
  - app/controllers/hr_lite/appraisals_controller.rb
@@ -120,6 +121,7 @@ files:
120
121
  - app/models/hr_lite/leave_type.rb
121
122
  - app/models/hr_lite/office_location.rb
122
123
  - app/models/hr_lite/payroll_run.rb
124
+ - app/models/hr_lite/professional_tax_slab.rb
123
125
  - app/models/hr_lite/regularization_request.rb
124
126
  - app/models/hr_lite/resignation.rb
125
127
  - app/models/hr_lite/role.rb
@@ -128,6 +130,7 @@ files:
128
130
  - app/models/hr_lite/salary_slip.rb
129
131
  - app/models/hr_lite/salary_structure.rb
130
132
  - app/models/hr_lite/setting.rb
133
+ - app/models/hr_lite/statutory_rate_card_record.rb
131
134
  - app/services/hr_lite/access.rb
132
135
  - app/services/hr_lite/attendance_puncher.rb
133
136
  - app/services/hr_lite/attendance_summary.rb
@@ -186,6 +189,10 @@ files:
186
189
  - app/views/hr_lite/admin/salary_structures/new.html.erb
187
190
  - app/views/hr_lite/admin/settings/edit.html.erb
188
191
  - app/views/hr_lite/admin/shared/_approvals_tabs.html.erb
192
+ - app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb
193
+ - app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb
194
+ - app/views/hr_lite/admin/statutory_rate_cards/index.html.erb
195
+ - app/views/hr_lite/admin/statutory_rate_cards/new.html.erb
189
196
  - app/views/hr_lite/appraisals/index.html.erb
190
197
  - app/views/hr_lite/appraisals/show.html.erb
191
198
  - app/views/hr_lite/attendance/_month_grid.html.erb
@@ -255,6 +262,7 @@ files:
255
262
  - db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb
256
263
  - db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb
257
264
  - db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb
265
+ - db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb
258
266
  - lib/generators/hr_lite/install/install_generator.rb
259
267
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
260
268
  - lib/generators/hr_lite/install/templates/initializer.rb
@@ -273,6 +281,7 @@ files:
273
281
  - lib/hr_lite/role_seeds.rb
274
282
  - lib/hr_lite/seeds.rb
275
283
  - lib/hr_lite/statutory_rate_card.rb
284
+ - lib/hr_lite/statutory_seeds.rb
276
285
  - lib/hr_lite/version.rb
277
286
  - lib/tasks/hr_lite_tasks.rake
278
287
  homepage: https://github.com/kshtzkr/hr_lite