hr_lite 0.5.2 → 0.5.3

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: d22267458cedf1755a676934b2488e404e78e26ea9c2b803f9dd72ee828b620d
4
- data.tar.gz: 50605b10283eb03f12bde5884917c033aebf58e7afc968de790f9581ff6cf8d4
3
+ metadata.gz: bdcc58ee5fdba440b0f70b9f22b85d85d20ef02f16c92481d4aad89a2395489f
4
+ data.tar.gz: f705fe091062bbe92246fd19fb1615c9fd9e0f2c6ed34ece293d155cc81879b1
5
5
  SHA512:
6
- metadata.gz: 7cc91aa10c5d70555bfedb8870d3d87cac4b06ea762222b768a120abae7ed5f46d94c96091aee92a397eb2390e5432611ede42076b78d02c060dcba4da3ebd53
7
- data.tar.gz: 5e850af90388970e343448c95533a7251ce0834400f33c6570feef5eddb511621e888d9b7c28b1cbb6391bb70941730e1f2804470d9c1bf1a5439ea7edc85c38
6
+ metadata.gz: c674e21d22c69a6b2ff5e51cbbb329f166140054212d81a2582c5d0b421350a732c94ae2b8074e4da15f4b510f9a0fa5bfceda84c633b57def295c2066276370
7
+ data.tar.gz: 514bf9fa94450d2ccdd4a7443125ec355dd4d68e99c264eac62fe3b5b3be2034a17a64c94571ef51580b97a8ebd59ba85e0a05a55474e3ec80dd9a4ebf798a7d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.3] - 2026-08-17
11
+
12
+ Correctness and traceability pass, ahead of the permissions work in 0.6.0.
13
+ **Adds one migration** — check constraints on every status column, plus an
14
+ index and foreign key on `hr_lite_designation_changes.appraisal_id`. It
15
+ refuses to run, naming the table and the offending values, if a live row
16
+ already holds a status no model declares.
17
+
18
+ ### Fixed
19
+
20
+ - **Payroll ran silently on an out-of-date statutory card.** The lookup fell
21
+ back to the newest card on or before the run month with no signal, so a run
22
+ in a financial year the gem ships no card for computed PF, ESI, PT and TDS
23
+ on the previous year's figures and said nothing. It still falls back —
24
+ payroll cannot stop dead every 1 April — but `PayrollRunProcessor` now puts
25
+ a warning naming both financial years at the TOP of the run's warnings, on
26
+ every compute. Adding a year remains one new entry in
27
+ `StatutoryRateCard::CARDS`; rates are never inferred forward.
28
+ - **A blank email could be granted the leadership tier.** `leadership_check`
29
+ did not drop empty entries the way `superadmin_check` did, so one stray
30
+ comma in `HR_LEADERSHIP_EMAILS` ("a@x.com,,b@x.com") put `""` on the list
31
+ and any user whose email was blank or nil matched it. Both checks now share
32
+ `HrLite.email_listed?`, which refuses a blank address against any list.
33
+ - Comp-off approval no longer reaches for a plural that cannot occur — a
34
+ credit is 0.5 or 1 day and never more.
35
+
36
+ ### Added
37
+
38
+ - **The money path leaves a trail.** Payroll compute, finalize, unlock and
39
+ publish, every leave approval, rejection and cancellation, and the payout
40
+ register download now write `AuditLog` rows. Previously a run could be
41
+ computed, finalized, unlocked, edited and republished and the audit screen
42
+ showed none of it.
43
+ - These rows are written inside the same transaction as the change they
44
+ describe and RAISE on failure, unlike the `Audited` concern, which stays
45
+ best-effort for ordinary policy edits. A payroll transition nobody can
46
+ account for afterwards does not happen at all.
47
+ - Amounts are deliberately excluded — `audit_logs` is not encrypted. Rows
48
+ record who moved a run, how far, and over how many people. Leave rows
49
+ carry the approver's note and never the employee's own reason.
50
+ - `HrLite::PayrollRun` and `HrLite::SalarySlip` join `MONEY_TIER_TYPES`, so
51
+ payroll rows stay out of the leadership audit screen.
52
+ - `HrLite::AuditLog.record!` — the five-line `create!` that five call sites
53
+ had each written out.
54
+ - `HrLite::FinancialYear` — the Indian FY (1 April) worked out in one place
55
+ instead of three.
56
+
57
+ ### Changed
58
+
59
+ - Statuses are enforced by the database. `update_column`, `update_all`,
60
+ `insert_all` and console fixes can no longer write a value that no screen
61
+ renders and no transition accepts.
62
+ - The suite enforces its own coverage: 100% line, 90% branch, 90% per file.
63
+ The README claimed 100% and nothing checked it; switching the floor on found
64
+ eight untested paths, including all three slip-override money guards (LOP
65
+ above the days in the month, negative LOP, negative TDS) and the offboarding
66
+ error path. All are covered now.
67
+ - `docs/PAYROLL.md` no longer lists ESI contribution-period lock-in as
68
+ unmodelled — 0.5.2 implemented it.
69
+
10
70
  ## [0.5.2] - 2026-08-08
11
71
 
12
72
  The rest of the audit that produced 0.5.1. **Adds three migrations** — they
@@ -349,7 +409,8 @@ Initial release.
349
409
  bus with per-event channel matrix (bell, email, leadership email/bell),
350
410
  daily leadership digest and an append-only audit trail.
351
411
 
352
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...HEAD
412
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...HEAD
413
+ [0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
353
414
  [0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
354
415
  [0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
355
416
  [0.5.0]: https://github.com/kshtzkr/hr_lite/compare/v0.4.0...v0.5.0
@@ -75,10 +75,9 @@ module HrLite
75
75
  private
76
76
 
77
77
  def log_regularization(record, note, removed:)
78
- AuditLog.create!(
79
- actor: hr_current_user, action: removed ? "destroy" : "regularize",
80
- subject_type: record.class.name, subject_id: record.id || 0,
81
- audited_changes: { "date" => record.date.to_s, "note" => note }
78
+ AuditLog.record!(
79
+ action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
80
+ changes: { "date" => record.date.to_s, "note" => note }
82
81
  )
83
82
  Notifications.publish(
84
83
  "attendance.regularized",
@@ -23,11 +23,10 @@ module HrLite
23
23
 
24
24
  balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
25
25
 
26
- AuditLog.create!(
27
- actor: hr_current_user, action: "adjust",
28
- subject_type: balance.class.name, subject_id: balance.id,
29
- audited_changes: { "user" => HrLite.display_name(user), "type" => type.code,
30
- "delta" => delta.to_f, "note" => note }
26
+ AuditLog.record!(
27
+ action: "adjust", subject: balance, actor: hr_current_user,
28
+ changes: { "user" => HrLite.display_name(user), "type" => type.code,
29
+ "delta" => delta.to_f, "note" => note }
31
30
  )
32
31
  redirect_to admin_leave_balances_path(year: year),
33
32
  notice: "Balance adjusted for #{HrLite.display_name(user)}."
@@ -55,8 +55,17 @@ module HrLite
55
55
  # deliberately leadership-only PII.
56
56
  def register
57
57
  run = PayrollRun.find(params[:id])
58
- send_data register_csv(run), filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
59
- type: "text/csv"
58
+ csv = register_csv(run)
59
+ # A GET that walks out of the building with every employee's pay and
60
+ # bank account. Nothing else in the module leaks this much in one
61
+ # click, so the download itself is the auditable event.
62
+ AuditLog.record!(
63
+ action: "payroll.register_exported", subject: run, actor: hr_current_user,
64
+ changes: { "period" => run.label, "rows" => run.salary_slips.count,
65
+ "includes_bank_details" => true }
66
+ )
67
+ send_data csv, filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
68
+ type: "text/csv"
60
69
  end
61
70
 
62
71
  private
@@ -43,12 +43,11 @@ module HrLite
43
43
  )
44
44
  slip.update!(attributes)
45
45
 
46
- AuditLog.create!(
47
- actor: hr_current_user, action: "override",
48
- subject_type: slip.class.name, subject_id: slip.id,
49
- audited_changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
50
- "lop_override" => lop_override || "cleared",
51
- "tds_override" => tds_override ? "[changed]" : "cleared" }
46
+ AuditLog.record!(
47
+ action: "override", subject: slip, actor: hr_current_user,
48
+ changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
49
+ "lop_override" => lop_override || "cleared",
50
+ "tds_override" => tds_override ? "[changed]" : "cleared" }
52
51
  )
53
52
  redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
54
53
  rescue ArgumentError
@@ -12,11 +12,36 @@ module HrLite
12
12
  # encrypt their amounts so the diff is already redacted, but appraisal
13
13
  # ratings and review text are plain columns — they must not reach the
14
14
  # leadership audit screen or the policy.changed email.
15
- MONEY_TIER_TYPES = %w[HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure].freeze
15
+ MONEY_TIER_TYPES = %w[
16
+ HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
+ HrLite::PayrollRun HrLite::SalarySlip
18
+ ].freeze
16
19
 
17
20
  scope :recent, -> { order(created_at: :desc) }
18
21
  scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
19
22
 
23
+ # The five-line `create!` that four call sites had each spelled out.
24
+ #
25
+ # This RAISES, unlike the Audited concern, which swallows failures so an
26
+ # audit hiccup cannot roll back an ordinary policy edit. On the money
27
+ # path that trade goes the other way: a payroll transition nobody can
28
+ # explain afterwards should not be allowed to happen at all, so callers
29
+ # run it inside the same transaction as the write it describes.
30
+ #
31
+ # `changes` is a whitelist the caller writes out by hand. Never pass
32
+ # amounts — this table is not encrypted.
33
+ def self.record!(action:, subject:, actor: HrLite::Current.actor, changes: {})
34
+ create!(
35
+ actor: actor,
36
+ action: action,
37
+ subject_type: subject.class.name,
38
+ # A destroyed record has no id left, and the row still has to say
39
+ # what happened.
40
+ subject_id: subject.id || 0,
41
+ audited_changes: changes
42
+ )
43
+ end
44
+
20
45
  def money_tier?
21
46
  MONEY_TIER_TYPES.include?(subject_type)
22
47
  end
@@ -54,7 +54,9 @@ module HrLite
54
54
 
55
55
  Notifications.publish(
56
56
  "comp_off.approved",
57
- title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
57
+ # credit_days is 0.5 or 1 and nothing else, so there is no plural to
58
+ # reach for here.
59
+ title: "Comp-off approved — #{credit_days.to_f} day credited for #{date_worked.strftime('%d %b')}",
58
60
  body: decision_note.presence,
59
61
  path: "/comp_off_requests",
60
62
  bell_to: [ user ], email_to: [ user ]
@@ -78,10 +78,15 @@ module HrLite
78
78
 
79
79
  def cancel!(actor:)
80
80
  was_approved = approved?
81
- self.status = "cancelled"
82
- self.decided_by_id = actor.id
83
- self.decided_at = Time.current
84
- save!
81
+ transaction do
82
+ self.status = "cancelled"
83
+ self.decided_by_id = actor.id
84
+ self.decided_at = Time.current
85
+ save!
86
+ # Cancelling an APPROVED leave hands the days back to the balance, so
87
+ # it is a quota change as much as a status change.
88
+ audit!("cancelled", actor, was_approved ? "was approved" : nil)
89
+ end
85
90
 
86
91
  Notifications.publish(
87
92
  "leave.cancelled",
@@ -117,9 +122,27 @@ module HrLite
117
122
  self.decided_at = Time.current
118
123
  self.decision_note = note
119
124
  save!
125
+ audit!(new_status, actor, note)
120
126
  end
121
127
  end
122
128
 
129
+ # Approve and reject both come through `transition!`, and `with_lock` is
130
+ # already a transaction — so a failed audit row rolls the decision back
131
+ # rather than leaving one that nobody can account for. The employee's own
132
+ # `reason` is deliberately not copied here; the approver's note is.
133
+ def audit!(action, actor, note)
134
+ AuditLog.record!(
135
+ action: "leave.#{action}", subject: self, actor: actor,
136
+ changes: {
137
+ "employee" => HrLite.display_name(user),
138
+ "type" => leave_type.code,
139
+ "dates" => date_range_label,
140
+ "days" => days_count&.to_s("F"),
141
+ "note" => note.presence
142
+ }.compact
143
+ )
144
+ end
145
+
123
146
  # This request is still pending here, so balance.used excludes it:
124
147
  # approving is valid iff the request fits the remaining balance.
125
148
  def insufficient_balance_now?
@@ -39,7 +39,12 @@ module HrLite
39
39
  begin
40
40
  update!(status: "processing")
41
41
  PayrollRunProcessor.call(self)
42
- update!(status: "review", processed_at: Time.current)
42
+ transaction do
43
+ update!(status: "review", processed_at: Time.current)
44
+ audit!("payroll.computed", actor,
45
+ "slips" => salary_slips.count, "warnings" => warnings.length,
46
+ "from_status" => previous)
47
+ end
43
48
  true
44
49
  rescue => e
45
50
  # Restore what the run WAS. Hardcoding "draft" here used to demote a
@@ -54,7 +59,12 @@ module HrLite
54
59
  raise_unless %w[review]
55
60
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
56
61
 
57
- update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
62
+ transaction do
63
+ update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
64
+ # Finalizing freezes every slip in the run. Who did it, to how many
65
+ # people, is the row an investigation starts from.
66
+ audit!("payroll.finalized", actor, "slips" => salary_slips.count)
67
+ end
58
68
  Notifications.publish(
59
69
  "payroll.finalized",
60
70
  title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
@@ -65,13 +75,21 @@ module HrLite
65
75
 
66
76
  def unlock!(actor:)
67
77
  raise_unless %w[finalized]
68
- update!(status: "review")
78
+ transaction do
79
+ update!(status: "review")
80
+ # Reopening frozen slips for editing is the single most sensitive
81
+ # transition in the module — it is the one that has to leave a trace.
82
+ audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
83
+ end
69
84
  true
70
85
  end
71
86
 
72
87
  def publish!(actor:)
73
88
  raise_unless %w[finalized]
74
- update!(status: "published", published_at: Time.current, published_by_id: actor.id)
89
+ transaction do
90
+ update!(status: "published", published_at: Time.current, published_by_id: actor.id)
91
+ audit!("payroll.published", actor, "slips" => salary_slips.count)
92
+ end
75
93
 
76
94
  slips = salary_slips.includes(:user).to_a
77
95
  # Everyone is emailed — a final settlement matters most to the person who
@@ -106,6 +124,14 @@ module HrLite
106
124
 
107
125
  private
108
126
 
127
+ # Amounts stay out of it on purpose: audit_logs is a plaintext table and
128
+ # every slip amount in this module is encrypted. The row records WHO
129
+ # moved the run and HOW FAR, never what anyone is paid.
130
+ def audit!(action, actor, changes)
131
+ AuditLog.record!(action: action, subject: self, actor: actor,
132
+ changes: changes.merge("period" => label))
133
+ end
134
+
109
135
  def sum_slips(attribute)
110
136
  salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
111
137
  end
@@ -71,10 +71,9 @@ module HrLite
71
71
  raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
72
72
 
73
73
  record.save!
74
- AuditLog.create!(
75
- actor: actor, action: "regularize",
76
- subject_type: record.class.name, subject_id: record.id,
77
- audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
74
+ AuditLog.record!(
75
+ action: "regularize", subject: record, actor: actor,
76
+ changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
78
77
  )
79
78
  end
80
79
 
@@ -40,8 +40,7 @@ module HrLite
40
40
  # Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
41
41
  # feeds the TDS projector and the slip's YTD table.
42
42
  def self.fy_to_date(user, period_month)
43
- fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
44
- : Date.new(period_month.year - 1, 4, 1)
43
+ fy_start = FinancialYear.start_for(period_month)
45
44
  slips = settled.where(user_id: user.id)
46
45
  .where(period_month: fy_start...period_month)
47
46
  {
@@ -12,7 +12,10 @@ module HrLite
12
12
  end
13
13
 
14
14
  def call
15
- warnings = []
15
+ # First in the list on purpose: if the statutory card is from an older
16
+ # financial year, every number below it is computed on last year's
17
+ # rates and that is the thing to read first.
18
+ warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
16
19
 
17
20
  ActiveRecord::Base.transaction do
18
21
  eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
@@ -0,0 +1,63 @@
1
+ class AddStatusConstraintsAndAppraisalFkToHrLite < ActiveRecord::Migration[8.1]
2
+ # Every status column in the engine is a plain string validated only in
3
+ # Ruby, so anything that skips validations — update_column, update_all,
4
+ # insert_all, a console fix, a future migration — can write a value no
5
+ # screen renders and no transition accepts. These constraints put the
6
+ # allowed set somewhere it cannot be bypassed.
7
+ #
8
+ # Deliberately in step with each model's own STATUSES array: adding a
9
+ # status now means writing a migration, which is the point.
10
+ STATUSES = {
11
+ hr_lite_attendance_records: %w[present half_day],
12
+ hr_lite_leave_requests: %w[pending approved rejected cancelled],
13
+ hr_lite_comp_off_requests: %w[pending approved rejected cancelled],
14
+ hr_lite_regularization_requests: %w[pending approved rejected cancelled],
15
+ hr_lite_resignations: %w[pending accepted withdrawn],
16
+ hr_lite_appraisals: %w[draft shared],
17
+ hr_lite_payroll_runs: %w[draft processing review finalized published]
18
+ }.freeze
19
+
20
+ def up
21
+ # On an install that already holds an off-list value the constraint build
22
+ # fails with a bare integrity error naming neither the row nor the value.
23
+ # Say what is actually in the way instead — this runs on live data.
24
+ offenders = STATUSES.filter_map do |table, statuses|
25
+ found = select_values(
26
+ "SELECT DISTINCT status FROM #{table} WHERE status NOT IN (#{quoted(statuses)})"
27
+ )
28
+ "#{table}: #{found.join(', ')}" if found.any?
29
+ end
30
+
31
+ if offenders.any?
32
+ raise ActiveRecord::IrreversibleMigration,
33
+ "Cannot constrain status columns — these rows hold values no model " \
34
+ "declares. Correct them first:\n #{offenders.join("\n ")}"
35
+ end
36
+
37
+ STATUSES.each do |table, statuses|
38
+ add_check_constraint table, status_sql(statuses), name: constraint_name(table)
39
+ end
40
+
41
+ # has_one :designation_change, dependent: :nullify — with neither an index
42
+ # nor a foreign key. Destroying an appraisal scanned the whole table to
43
+ # find its promotion row, and nothing stopped that row from being left
44
+ # pointing at an appraisal that no longer exists.
45
+ add_index :hr_lite_designation_changes, :appraisal_id
46
+ add_foreign_key :hr_lite_designation_changes, :hr_lite_appraisals,
47
+ column: :appraisal_id, on_delete: :nullify
48
+ end
49
+
50
+ def down
51
+ remove_foreign_key :hr_lite_designation_changes, column: :appraisal_id
52
+ remove_index :hr_lite_designation_changes, :appraisal_id
53
+ STATUSES.each_key { |table| remove_check_constraint table, name: constraint_name(table) }
54
+ end
55
+
56
+ private
57
+
58
+ def status_sql(statuses) = "status IN (#{quoted(statuses)})"
59
+
60
+ def quoted(statuses) = statuses.map { |s| connection.quote(s) }.join(", ")
61
+
62
+ def constraint_name(table) = "#{table}_status_check"
63
+ end
@@ -46,16 +46,13 @@ module HrLite
46
46
  @currency_symbol = "₹"
47
47
  @on_designation_change = ->(user, designation) { }
48
48
  @leadership_emails = []
49
- @leadership_check = ->(user) do
50
- emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
51
- emails.include?(user.email.to_s.downcase)
52
- end
49
+ @leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
53
50
  # Money tier: salary structures, payroll, slips, appraisals. Empty
54
51
  # list means "same as leadership" (pre-0.5.0 behaviour).
55
52
  @superadmin_emails = []
56
53
  @superadmin_check = ->(user) do
57
- emails = HrLite.config.superadmin_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
58
- emails.empty? ? HrLite.leadership?(user) : emails.include?(user.email.to_s.downcase)
54
+ list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
55
+ list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
59
56
  end
60
57
  @extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
61
58
  @mailer_from = "hr@example.com"
@@ -0,0 +1,26 @@
1
+ module HrLite
2
+ # The Indian financial year runs 1 April – 31 March. Three places worked
3
+ # this out for themselves (the slip's year-to-date sums, the TDS projector
4
+ # and the rate card); it lives here once so they cannot drift apart.
5
+ module FinancialYear
6
+ START_MONTH = 4
7
+
8
+ # First day of the FY containing `date`. March 2027 belongs to the year
9
+ # that opened on 1 April 2026.
10
+ def self.start_for(date)
11
+ year = date.month >= START_MONTH ? date.year : date.year - 1
12
+ Date.new(year, START_MONTH, 1)
13
+ end
14
+
15
+ # "2026-27" — for warnings and screens, never for arithmetic.
16
+ def self.label(date)
17
+ start = start_for(date)
18
+ "#{start.year}-#{format('%02d', (start.year + 1) % 100)}"
19
+ end
20
+
21
+ # True when `date` falls in an earlier FY than `other`.
22
+ def self.before?(date, other)
23
+ start_for(date) < start_for(other)
24
+ end
25
+ end
26
+ end
@@ -3,9 +3,13 @@ module HrLite
3
3
  # is a one-hash edit that gets code review and a spec diff, never an
4
4
  # inline-constant hunt.
5
5
  #
6
- # VERIFY WITH A CA before the first run of any new financial year; the
7
- # FY 2026-27 card ships with FY 2025-26 figures pending confirmation of
8
- # the Feb 2026 Finance Act.
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.
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
13
  module StatutoryRateCard
10
14
  def self.r(value) = BigDecimal(value.to_s)
11
15
  private_class_method :r
@@ -58,8 +62,44 @@ module HrLite
58
62
  }.freeze
59
63
 
60
64
  def self.for(period_month)
61
- effective = CARDS.keys.sort.reverse.find { |date| date <= period_month }
62
- effective ? CARDS[effective] : CARDS[CARDS.keys.min]
65
+ CARDS[effective_date_for(period_month)]
66
+ end
67
+
68
+ # Which card a run will actually use. A month older than every card
69
+ # borrows the earliest one — see `predates_cards?`, which says so.
70
+ def self.effective_date_for(period_month)
71
+ CARDS.keys.sort.reverse.find { |date| date <= period_month } || CARDS.keys.min
72
+ end
73
+
74
+ # The card in force is from an EARLIER financial year than the run. The
75
+ # lookup still returns figures — it has to, or payroll would stop dead
76
+ # every April — so the run carries the warning instead.
77
+ def self.stale_for?(period_month)
78
+ FinancialYear.before?(effective_date_for(period_month), period_month)
79
+ end
80
+
81
+ # The run is older than every card we ship, so it is being computed on
82
+ # rates that had not been announced yet.
83
+ def self.predates_cards?(period_month)
84
+ period_month < CARDS.keys.min
85
+ end
86
+
87
+ # One sentence naming both the run's FY and the card's, or nil when they
88
+ # match. Rendered verbatim in the run's warnings list.
89
+ def self.warning_for(period_month)
90
+ card_fy = FinancialYear.label(effective_date_for(period_month))
91
+ run_fy = FinancialYear.label(period_month)
92
+
93
+ if predates_cards?(period_month)
94
+ "Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
95
+ "statutory card — no card ships for a year that early. PF, ESI, PT " \
96
+ "and TDS on this run are not the rates that applied. Verify with your CA."
97
+ elsif stale_for?(period_month)
98
+ "Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
99
+ "statutory card — no card ships for FY #{run_fy} yet. PF, ESI, PT and " \
100
+ "TDS on this run use last year's rates. Add a CA-verified card for " \
101
+ "FY #{run_fy} before publishing."
102
+ end
63
103
  end
64
104
  end
65
105
  end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.5.2"
2
+ VERSION = "0.5.3"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -3,6 +3,7 @@ require "hr_lite/engine"
3
3
  require "hr_lite/configuration"
4
4
  require "hr_lite/current"
5
5
  require "hr_lite/leave_year"
6
+ require "hr_lite/financial_year"
6
7
  require "hr_lite/mention_parser"
7
8
  require "hr_lite/notifications"
8
9
  require "hr_lite/seeds"
@@ -42,11 +43,27 @@ module HrLite
42
43
  user.present? && !!config.superadmin_check.call(user)
43
44
  end
44
45
 
46
+ # An access list, cleaned. "a@x.com,,b@x.com".split(",") — one stray
47
+ # comma in an ENV var — used to put "" in the list, and a user whose
48
+ # email was blank then MATCHED it and was handed the tier.
49
+ def normalize_email_list(emails)
50
+ Array(emails).map { |e| e.to_s.downcase.strip }.reject(&:empty?)
51
+ end
52
+
53
+ # Whether a user's own email is on a configured access list. A blank
54
+ # email is never on any list, however the list is spelled.
55
+ def email_listed?(user, emails)
56
+ address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
57
+ return false if address.empty?
58
+
59
+ normalize_email_list(emails).include?(address)
60
+ end
61
+
45
62
  # Leadership members resolvable to actual user records (for bell
46
63
  # notifications). Emails configured but absent from the user table are
47
64
  # still reachable by email — see Notifications.
48
65
  def leadership_users
49
- emails = config.leadership_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
66
+ emails = normalize_email_list(config.leadership_emails)
50
67
  return user_klass.none if emails.empty?
51
68
 
52
69
  user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
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.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -242,6 +242,7 @@ files:
242
242
  - db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb
243
243
  - db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb
244
244
  - db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb
245
+ - db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb
245
246
  - lib/generators/hr_lite/install/install_generator.rb
246
247
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
247
248
  - lib/generators/hr_lite/install/templates/initializer.rb
@@ -250,6 +251,7 @@ files:
250
251
  - lib/hr_lite/configuration.rb
251
252
  - lib/hr_lite/current.rb
252
253
  - lib/hr_lite/engine.rb
254
+ - lib/hr_lite/financial_year.rb
253
255
  - lib/hr_lite/geo.rb
254
256
  - lib/hr_lite/leave_year.rb
255
257
  - lib/hr_lite/mention_parser.rb