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 +4 -4
- data/CHANGELOG.md +57 -1
- data/README.md +1 -1
- data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +97 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -1
- data/app/models/hr_lite/professional_tax_slab.rb +41 -0
- data/app/models/hr_lite/statutory_rate_card_record.rb +95 -0
- data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
- data/app/services/hr_lite/payroll_run_processor.rb +17 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
- data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +1 -1
- data/lib/hr_lite/configuration.rb +1 -1
- data/lib/hr_lite/statutory_rate_card.rb +55 -10
- data/lib/hr_lite/statutory_seeds.rb +71 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +1 -0
- data/lib/tasks/hr_lite_tasks.rake +9 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1716d66b8a6859e70f044dc0025b2052a8085ad83c82567540cbad04a216be9d
|
|
4
|
+
data.tar.gz: bc4d26d3c69be4f280e55820672892fac8ae6a93c190be3b6b4272adda7754b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
4
|
-
# states
|
|
5
|
-
#
|
|
3
|
+
# State-slab professional tax on earned gross. Slabs may carry a
|
|
4
|
+
# feb_extra for the states that top up the last month's deduction.
|
|
5
|
+
#
|
|
6
|
+
# Slabs come from `hr_lite_professional_tax_slabs`, dated per state,
|
|
7
|
+
# falling back to whatever `rates` hash the caller passes for a host that
|
|
8
|
+
# has not seeded yet. A state nobody configured yields zero — and
|
|
9
|
+
# `unconfigured?` is how payroll gets to SAY so, because a confident zero
|
|
10
|
+
# is the failure mode here, not an exception.
|
|
6
11
|
module ProfessionalTax
|
|
7
|
-
def self.call(state:, gross_earned:, period_month:, rates:)
|
|
8
|
-
slabs =
|
|
12
|
+
def self.call(state:, gross_earned:, period_month:, rates: {})
|
|
13
|
+
slabs = slabs_for(state, period_month, rates)
|
|
9
14
|
earned = Money.d(gross_earned)
|
|
10
15
|
|
|
11
16
|
# `from:` is an INCLUSIVE lower bound — ₹25,000 is taxed, ₹24,999.50 is
|
|
@@ -19,6 +24,30 @@ module HrLite
|
|
|
19
24
|
amount += slab[:feb_extra] if slab[:feb_extra] && period_month.month == 2
|
|
20
25
|
Money.round_rupee(amount)
|
|
21
26
|
end
|
|
27
|
+
|
|
28
|
+
def self.slabs_for(state, period_month, rates = {})
|
|
29
|
+
stored = stored_slabs(state, period_month)
|
|
30
|
+
return stored if stored.present?
|
|
31
|
+
|
|
32
|
+
rates[state.to_s] || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A state with no stored slabs and none in the fallback table. That is
|
|
36
|
+
# NOT the same as a state with no professional tax: Karnataka was the
|
|
37
|
+
# only state ever entered in code, so every other one deducted nothing
|
|
38
|
+
# and nothing said so.
|
|
39
|
+
def self.unconfigured?(state, period_month, rates = {})
|
|
40
|
+
return false if state.to_s == ProfessionalTaxSlab::NO_LEVY
|
|
41
|
+
|
|
42
|
+
slabs_for(state, period_month, rates).empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.stored_slabs(state, period_month)
|
|
46
|
+
ProfessionalTaxSlab.table_for(state, period_month)
|
|
47
|
+
rescue ActiveRecord::ActiveRecordError
|
|
48
|
+
# Host mid-migration: gem upgraded, db:migrate not yet run.
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
22
51
|
end
|
|
23
52
|
end
|
|
24
53
|
end
|
|
@@ -16,10 +16,12 @@ module HrLite
|
|
|
16
16
|
# financial year, every number below it is computed on last year's
|
|
17
17
|
# rates and that is the thing to read first.
|
|
18
18
|
warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
|
|
19
|
+
warnings.concat(Array(StatutoryRateCard.unverified_for(@run.period_month)))
|
|
19
20
|
|
|
20
21
|
ActiveRecord::Base.transaction do
|
|
21
22
|
eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
|
|
22
23
|
keep_ids = []
|
|
24
|
+
unconfigured_states = []
|
|
23
25
|
|
|
24
26
|
eligible.each do |profile|
|
|
25
27
|
user = profile.user
|
|
@@ -29,6 +31,14 @@ module HrLite
|
|
|
29
31
|
next
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Professional tax is a state levy, and a state nobody has entered
|
|
35
|
+
# slabs for deducts nothing — which looks exactly like a state that
|
|
36
|
+
# levies nothing. Collected per state rather than per employee so a
|
|
37
|
+
# forty-person office is one line, not forty.
|
|
38
|
+
if Calculators::ProfessionalTax.unconfigured?(structure.pt_state, @run.period_month)
|
|
39
|
+
unconfigured_states << structure.pt_state
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
slip = @run.salary_slips.find_or_initialize_by(user_id: user.id)
|
|
33
43
|
attributes = SlipBuilder.call(
|
|
34
44
|
run: @run, user: user, structure: structure, profile: profile,
|
|
@@ -44,6 +54,13 @@ module HrLite
|
|
|
44
54
|
keep_ids << slip.id
|
|
45
55
|
end
|
|
46
56
|
|
|
57
|
+
unconfigured_states.uniq.sort.each do |state|
|
|
58
|
+
warnings << "No professional-tax slabs are configured for " \
|
|
59
|
+
"#{state.to_s.humanize} — everybody there is being deducted ₹0. " \
|
|
60
|
+
"Enter that state's bands, or set their structures to 'none' if " \
|
|
61
|
+
"it genuinely levies no PT."
|
|
62
|
+
end
|
|
63
|
+
|
|
47
64
|
@run.salary_slips.where.not(id: keep_ids).destroy_all
|
|
48
65
|
@run.update!(warnings: warnings)
|
|
49
66
|
end
|
|
@@ -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,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>
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
7
|
-
# a year is
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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 <
|
|
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
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|