hr_lite 0.6.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7dd5723f67a89de78893c7ef752581da400b8703fb60876ad7d283e280308d31
4
- data.tar.gz: d8e517a4a80bb2026de50a9f30b7837b37b785d02a5aea79802ad72b78fcc630
3
+ metadata.gz: 1716d66b8a6859e70f044dc0025b2052a8085ad83c82567540cbad04a216be9d
4
+ data.tar.gz: bc4d26d3c69be4f280e55820672892fac8ae6a93c190be3b6b4272adda7754b2
5
5
  SHA512:
6
- metadata.gz: ea7291efd85233dfabf368fdc2355f8050920a22e324ea9f0ac083ba10040aaacaf125f93ed2d89b94e3ab05ed496375d69a6499af66372a974498677a30a18b
7
- data.tar.gz: 1c1ad921b96ae6d778711b61b62501b5a8fa2e6cfd08cb8fd2c162bc01d37c7f048bc9637a617f1eebcac2f51a01e669cff89d94adf3bd48724b7cb9c7959a05
6
+ metadata.gz: a0ce5511c8c763eac022ee7f900d3c92a0267057c0f6805e49d753076c9c07f6a842285c6d2f034cd3ee6fe40a2343865a4295fd696ef2f0ab4e2a44291c2ffe
7
+ data.tar.gz: bdfac21210c1a26383b2d9bed8782f093f651c479e5fe150fad94ad46a012a3612057098d4ea78a8f79f1a80652099c214e47954901cd546a8dd4b340e726b84
data/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-08-18
11
+
12
+ Statutory figures stop being code. **Adds one migration** (rate cards and
13
+ professional-tax slabs) and seeds it from the figures the gem already
14
+ shipped — no number changes on upgrade.
15
+
16
+ ### Fixed
17
+
18
+ - **Every state except Karnataka deducted ₹0 professional tax, silently.** PT
19
+ is a state levy and only Karnataka had bands in code; every other state
20
+ resolved to an empty slab list and deducted nothing, which is a wrong answer
21
+ delivered confidently. Slabs are now per state and effective-dated, and a
22
+ run whose employees sit in a state nobody has configured says so — once per
23
+ state, not once per employee.
24
+ - **Adding a financial year needed a gem release.** Every April was a deadline
25
+ the gem controlled and the company did not; 0.5.3 shipped a warning about
26
+ exactly that. Cards are rows now, entered on a screen.
27
+ - A tax regime saved with an empty slab list validated on key presence and
28
+ then taxed every salary at zero. Refused.
29
+
30
+ ### Added
31
+
32
+ - `hr_lite_statutory_rate_cards` — PF, ESI and both regimes' slabs per
33
+ financial year, effective-dated. A card must start on 1 April (a mid-year
34
+ card would mean two slab sets inside one year, and the TDS projector works
35
+ on an annual figure), and must carry every figure payroll reads — a card
36
+ missing one fails at save time rather than half way through computing
37
+ somebody's salary.
38
+ - `hr_lite_professional_tax_slabs` — per state, per date, with the February
39
+ top-up the states that levy one need.
40
+ - A rate-card screen (money tier). Adding a year copies the newest card, since
41
+ most years carry PF and ESI over untouched and move only the slabs — a blank
42
+ form would invite retyping eight numbers that were already right.
43
+ - **Sign-off.** A card records who checked it and when, and payroll warns on
44
+ every run computed against one nobody has confirmed. Ranked below the
45
+ wrong-financial-year warning: bad figures matter more than missing paperwork.
46
+ - `rake hr_lite:statutory`, and `hr_lite:seed` copies the shipped figures in.
47
+
48
+ ### Changed
49
+
50
+ - `StatutoryRateCard.for` reads the table, falling back to the shipped hash
51
+ for a host that has upgraded the gem but not yet run `db:migrate` — payroll
52
+ must not 500 on a missing table.
53
+ - Branch-coverage floor 90.5% -> 90.8%.
54
+
55
+ ### Deferred
56
+
57
+ - **`config.legacy_tier_checks` and the pre-0.6.0 tier lambdas are still
58
+ here.** 0.6.0 said they would go in this release. They have not: removing an
59
+ access mechanism in the same release that rewrites where statutory figures
60
+ live puts two unrelated upgrade risks in one step, and this release is
61
+ otherwise purely additive. They go in **0.8.0**, on their own. If you are
62
+ still relying on the flag, the 0.6.0 migration has already created the
63
+ equivalent role assignments — turn it off and check the Roles screen.
64
+
10
65
  ## [0.6.0] - 2026-08-18
11
66
 
12
67
  Access stops being three lambdas and becomes a role table. **Breaking**, with
@@ -458,7 +513,8 @@ Initial release.
458
513
  bus with per-event channel matrix (bell, email, leadership email/bell),
459
514
  daily leadership digest and an append-only audit trail.
460
515
 
461
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...HEAD
516
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...HEAD
517
+ [0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
462
518
  [0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
463
519
  [0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
464
520
  [0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
data/README.md CHANGED
@@ -211,7 +211,7 @@ It prints who it put where, and it leaves a host that has already assigned
211
211
  roles by hand alone.
212
212
 
213
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.
214
+ you migrate. It is honoured for one minor version and removed in 0.8.0.
215
215
 
216
216
  ## Features
217
217
 
@@ -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
@@ -18,7 +18,7 @@ HrLite.configure do |c|
18
18
  #
19
19
  # Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
20
20
  # your leadership_emails / superadmin_emails / admin_check as they were.
21
- # It is honoured until 0.7.0.
21
+ # It is honoured until 0.8.0.
22
22
  # c.legacy_tier_checks = true
23
23
 
24
24
  # Where the portal is reachable (subdomain or path). Enables email link
@@ -50,7 +50,7 @@ module HrLite
50
50
  # user's EMAIL — a mutable, host-owned, unverified column that a host
51
51
  # then had to remember never to let anyone edit. Roles replace them.
52
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.
53
+ # not finished migrating; honoured for one more minor version, gone in 0.8.0.
54
54
  @legacy_tier_checks = false
55
55
  @leadership_emails = []
56
56
  @leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
@@ -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.7.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"
@@ -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.7.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