hr_lite 0.4.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -1
  3. data/README.md +312 -99
  4. data/app/assets/javascripts/hr_lite/confirm.js +31 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
  6. data/app/controllers/hr_lite/admin/appraisals_controller.rb +1 -1
  7. data/app/controllers/hr_lite/admin/attendances_controller.rb +16 -1
  8. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
  9. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/employees_controller.rb +37 -13
  11. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +15 -8
  12. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  13. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +1 -1
  14. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  15. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +18 -3
  16. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +1 -1
  17. data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
  18. data/app/controllers/hr_lite/admin/superadmin_controller.rb +21 -0
  19. data/app/controllers/hr_lite/application_controller.rb +5 -1
  20. data/app/controllers/hr_lite/org_controller.rb +6 -1
  21. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  22. data/app/helpers/hr_lite/application_helper.rb +7 -13
  23. data/app/jobs/hr_lite/application_job.rb +5 -0
  24. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  25. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  26. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  27. data/app/models/concerns/hr_lite/audited.rb +11 -4
  28. data/app/models/hr_lite/appraisal.rb +9 -1
  29. data/app/models/hr_lite/audit_log.rb +11 -0
  30. data/app/models/hr_lite/comp_off_request.rb +5 -15
  31. data/app/models/hr_lite/designation_change.rb +18 -1
  32. data/app/models/hr_lite/employee_profile.rb +26 -1
  33. data/app/models/hr_lite/leave_balance.rb +51 -5
  34. data/app/models/hr_lite/leave_request.rb +9 -1
  35. data/app/models/hr_lite/leave_type.rb +13 -3
  36. data/app/models/hr_lite/payroll_run.rb +39 -10
  37. data/app/models/hr_lite/regularization_request.rb +8 -0
  38. data/app/models/hr_lite/resignation.rb +27 -0
  39. data/app/models/hr_lite/salary_slip.rb +7 -3
  40. data/app/models/hr_lite/setting.rb +2 -0
  41. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  42. data/app/services/hr_lite/calculators/esi.rb +7 -4
  43. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  44. data/app/services/hr_lite/calculators/tds.rb +8 -1
  45. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  46. data/app/services/hr_lite/overview_query.rb +12 -1
  47. data/app/services/hr_lite/payroll_run_processor.rb +5 -0
  48. data/app/services/hr_lite/slip_builder.rb +54 -8
  49. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  50. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  51. data/app/views/hr_lite/admin/employees/_form.html.erb +30 -3
  52. data/app/views/hr_lite/admin/employees/index.html.erb +3 -0
  53. data/app/views/hr_lite/admin/employees/show.html.erb +34 -18
  54. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  55. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  56. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  57. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  58. data/app/views/hr_lite/admin/settings/edit.html.erb +7 -0
  59. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  60. data/app/views/hr_lite/career/show.html.erb +7 -1
  61. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  62. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  63. data/app/views/layouts/hr_lite/application.html.erb +47 -4
  64. data/config/routes.rb +1 -1
  65. data/db/migrate/20260720094454_add_employee_code_prefix_to_hr_lite_settings.rb +7 -0
  66. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  67. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  68. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  69. data/lib/hr_lite/amount_in_words.rb +15 -2
  70. data/lib/hr_lite/configuration.rb +8 -0
  71. data/lib/hr_lite/money.rb +18 -0
  72. data/lib/hr_lite/notifications.rb +23 -8
  73. data/lib/hr_lite/statutory_rate_card.rb +6 -1
  74. data/lib/hr_lite/version.rb +1 -1
  75. data/lib/hr_lite.rb +9 -1
  76. metadata +7 -1
@@ -19,6 +19,34 @@ module HrLite
19
19
  find_or_initialize_by(user_id: user.id, leave_type: leave_type, year: year)
20
20
  end
21
21
 
22
+ # Persisted and row-locked so concurrent writers serialize here. The
23
+ # create sits in its own savepoint: on PostgreSQL a failed INSERT aborts
24
+ # the enclosing transaction, so a plain rescue could never run.
25
+ def self.lock_for(user, leave_type, year)
26
+ attributes = { user_id: user.id, leave_type_id: leave_type.id, year: year }
27
+ balance = find_by(attributes)
28
+ balance ||= begin
29
+ transaction(requires_new: true) { create!(attributes) }
30
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
31
+ find_by!(attributes)
32
+ end
33
+ balance.lock!
34
+ balance
35
+ end
36
+
37
+ # The single write path for the stored adjustment. Both callers — the
38
+ # comp-off credit and the admin correction — increment the same column,
39
+ # and read-modify-write without the lock lost one of them.
40
+ def self.adjust!(user, leave_type, year, delta:, note:)
41
+ transaction do
42
+ balance = lock_for(user, leave_type, year)
43
+ balance.adjustment += delta
44
+ balance.adjustment_note = [ balance.adjustment_note.presence, note ].compact.join("; ")
45
+ balance.save!
46
+ balance
47
+ end
48
+ end
49
+
22
50
  def entitled(as_of: Date.current)
23
51
  return Float::INFINITY if leave_type.unlimited?
24
52
 
@@ -30,7 +58,12 @@ module HrLite
30
58
  requests = LeaveRequest.approved
31
59
  .where(user_id: user_id, leave_type_id: leave_type_id)
32
60
  .where(start_date: range)
33
- requests.sum { |request| LeaveDayCounter.count(request) }
61
+ # ONE calendar for the whole leave year, reused across every request.
62
+ # Building one per request meant a Holiday query and a Setting query
63
+ # each — and the admin balances grid reads this cell for every employee
64
+ # times every leave type.
65
+ calendar = WorkingCalendar.new(range)
66
+ requests.sum { |request| LeaveDayCounter.count(request, calendar: calendar) }
34
67
  end
35
68
 
36
69
  def available(as_of: Date.current)
@@ -46,28 +79,41 @@ module HrLite
46
79
  def accrued_base(as_of)
47
80
  range = LeaveYear.range(year)
48
81
  start = accrual_start(range)
49
- return 0 if start.nil?
82
+ finish = accrual_end(range)
83
+ return 0 if start.nil? || start > finish
50
84
 
51
85
  monthly_rate = leave_type.annual_quota / 12
52
86
  months =
53
87
  if leave_type.accrual == "monthly"
54
- cap = [ as_of, range.last ].min
88
+ cap = [ as_of, finish ].min
55
89
  start > cap ? 0 : months_between(start, cap)
56
90
  else
57
- months_between(start, range.last)
91
+ months_between(start, finish)
58
92
  end
59
93
  monthly_rate * months
60
94
  end
61
95
 
62
96
  # nil when the person joins only after this leave year ends.
63
97
  def accrual_start(range)
64
- doj = EmployeeProfile.where(user_id: user_id).pick(:date_of_joining)
98
+ doj = employment_window.first
65
99
  return range.first if doj.nil? || doj <= range.first
66
100
 
67
101
  start = doj.day <= 15 ? doj.beginning_of_month : doj.next_month.beginning_of_month
68
102
  start > range.last ? nil : start
69
103
  end
70
104
 
105
+ # Quota stops accruing on the last working day — entitlement used to keep
106
+ # growing for months after someone had left.
107
+ def accrual_end(range)
108
+ exit_date = employment_window.last
109
+ exit_date ? [ exit_date, range.last ].min : range.last
110
+ end
111
+
112
+ def employment_window
113
+ @employment_window ||= EmployeeProfile.where(user_id: user_id)
114
+ .pick(:date_of_joining, :date_of_exit) || [ nil, nil ]
115
+ end
116
+
71
117
  def months_between(from, to)
72
118
  (to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
73
119
  end
@@ -44,6 +44,11 @@ module HrLite
44
44
  def approve!(actor:, note: nil)
45
45
  insufficient = false
46
46
  transition!("approved", actor, note) do
47
+ # Serialize on the BALANCE row. `transition!`'s own lock is on this
48
+ # request, and two pending requests are two different rows — so both
49
+ # approvals could read the same untouched balance and overdraw it.
50
+ LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
51
+
47
52
  if insufficient_balance_now?
48
53
  insufficient = true
49
54
  raise ActiveRecord::Rollback
@@ -120,7 +125,10 @@ module HrLite
120
125
  def insufficient_balance_now?
121
126
  return false if leave_type.unlimited?
122
127
 
123
- LeaveDayCounter.count(self) > balance.available
128
+ # Same as-of date the create-time check used. Reading it as of TODAY
129
+ # meant a request validated against the balance it would have on the
130
+ # leave dates could never be approved on a monthly-accrual type.
131
+ LeaveDayCounter.count(self) > balance.available(as_of: start_date)
124
132
  end
125
133
 
126
134
  def notify_requested
@@ -13,6 +13,9 @@ module HrLite
13
13
  validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
14
14
  validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
15
15
  validate :single_comp_off_type
16
+ # Retiring the flagged type hands the flag back, so a replacement can take
17
+ # it and `comp_off_type` (which resolves through `active`) keeps working.
18
+ before_save :release_comp_off_when_retired
16
19
 
17
20
  scope :active, -> { where(active: true).order(:position, :id) }
18
21
 
@@ -28,12 +31,19 @@ module HrLite
28
31
  private
29
32
 
30
33
  # Approvals credit into THE comp-off type — two flagged types would
31
- # make the credit target position-order roulette.
34
+ # make the credit target position-order roulette. Scoped to ACTIVE rows
35
+ # so it agrees with `comp_off_type`, which resolves through `active`:
36
+ # counting a retired type here made the flag impossible to move to its
37
+ # replacement, and comp-off approval then failed for everyone.
32
38
  def single_comp_off_type
33
- return unless comp_off
39
+ return unless comp_off && active
34
40
 
35
- clash = self.class.where(comp_off: true).where.not(id: id)
41
+ clash = self.class.active.where(comp_off: true).where.not(id: id)
36
42
  errors.add(:comp_off, "is already set on #{clash.first.name}") if clash.exists?
37
43
  end
44
+
45
+ def release_comp_off_when_retired
46
+ self.comp_off = false if comp_off && !active
47
+ end
38
48
  end
39
49
  end
@@ -13,6 +13,8 @@ module HrLite
13
13
  validates :period_month, presence: true, uniqueness: true
14
14
  validates :status, inclusion: { in: STATUSES }
15
15
  validate :period_is_first_of_month
16
+ # Create-only: an existing run must stay transitionable forever.
17
+ validate :period_is_a_closed_month, on: :create
16
18
  before_destroy :draft_only_destroy
17
19
 
18
20
  scope :recent_first, -> { order(period_month: :desc) }
@@ -27,16 +29,25 @@ module HrLite
27
29
  period_month.strftime("%B %Y")
28
30
  end
29
31
 
32
+ # `processing` is allowed back in so a run stranded there by a killed
33
+ # process (deploy roll, timeout) can be recomputed instead of blocking
34
+ # that month forever.
30
35
  def compute!(actor:)
31
- raise_unless %w[draft review]
32
-
33
- update!(status: "processing")
34
- PayrollRunProcessor.call(self)
35
- update!(status: "review", processed_at: Time.current)
36
- true
37
- rescue => e
38
- update_columns(status: "draft") # rubocop:disable Rails/SkipsModelValidations
39
- raise e
36
+ raise_unless %w[draft review processing]
37
+ previous = status
38
+
39
+ begin
40
+ update!(status: "processing")
41
+ PayrollRunProcessor.call(self)
42
+ update!(status: "review", processed_at: Time.current)
43
+ true
44
+ rescue => e
45
+ # Restore what the run WAS. Hardcoding "draft" here used to demote a
46
+ # finalized or published run — which then exposed the delete-draft
47
+ # control, and that cascades over every salary slip.
48
+ update_columns(status: previous) # rubocop:disable Rails/SkipsModelValidations
49
+ raise e
50
+ end
40
51
  end
41
52
 
42
53
  def finalize!(actor:)
@@ -63,12 +74,20 @@ module HrLite
63
74
  update!(status: "published", published_at: Time.current, published_by_id: actor.id)
64
75
 
65
76
  slips = salary_slips.includes(:user).to_a
77
+ # Everyone is emailed — a final settlement matters most to the person who
78
+ # has left — but a bell is only useful to someone who can still sign in,
79
+ # and offboarding revokes that. Sending one pointed at a page they cannot
80
+ # open is just noise in a place they will never look.
81
+ exited = EmployeeProfile.where(user_id: slips.map(&:user_id))
82
+ .where(date_of_exit: ..Date.current)
83
+ .pluck(:user_id).to_set
84
+
66
85
  Notifications.publish(
67
86
  "payroll.published",
68
87
  title: "Your salary slip for #{label} is ready",
69
88
  body: "Open Earthly HR to view or download it.",
70
89
  path: "/salary_slips",
71
- bell_to: slips.map(&:user),
90
+ bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
72
91
  email_to: slips.map(&:user)
73
92
  )
74
93
  true
@@ -102,6 +121,16 @@ module HrLite
102
121
  errors.add(:period_month, "must be the 1st of a month")
103
122
  end
104
123
 
124
+ # Days that have not happened yet score as `upcoming`, which payroll folds
125
+ # into payable — so computing a month before it ends pays for days nobody
126
+ # worked, and once finalized the slips are immutable.
127
+ def period_is_a_closed_month
128
+ return unless period_month
129
+ return if period_month.end_of_month < Date.current
130
+
131
+ errors.add(:period_month, "must be a month that has already ended")
132
+ end
133
+
105
134
  def draft_only_destroy
106
135
  return if draft?
107
136
 
@@ -49,6 +49,14 @@ module HrLite
49
49
  # InvalidMerge with the real story so the admin knows what to fix.
50
50
  def approve!(actor:, note: nil)
51
51
  transition!("approved", actor, note) do
52
+ # Re-checked at approval, not only at create: leave can be approved for
53
+ # that day while the ticket waits in the queue, and DayStatus ranks
54
+ # full-day leave above any punch — so the "fix" would write a record
55
+ # nothing reads while telling everyone attendance had been corrected.
56
+ if LeaveRequest.active_on(date).where(user_id: user_id, half_day: false).exists?
57
+ raise InvalidMerge, "the day is now covered by approved leave — cancel the leave first"
58
+ end
59
+
52
60
  record = AttendanceRecord.find_or_initialize_by(user_id: user_id, date: date)
53
61
  record.check_in_at = check_in_at if check_in_at
54
62
  record.check_out_at = check_out_at if check_out_at
@@ -29,9 +29,18 @@ module HrLite
29
29
  update!(status: "accepted", decided_by_id: actor.id, decided_at: Time.current,
30
30
  decision_note: note.presence, proposed_last_day: final_day)
31
31
  profile = EmployeeProfile.find_by(user_id: user_id)
32
+ # Someone can resign before HR ever created their profile. That is not
33
+ # a reason to refuse the resignation — but the caller has to know
34
+ # nothing was stamped, because the screen used to claim it was.
35
+ @exit_date_recorded = profile.present?
32
36
  profile&.update!(date_of_exit: final_day)
33
37
  end
34
38
 
39
+ # Notice periods are the normal case, so access is revoked here only
40
+ # when the last day has already passed. Otherwise the person keeps
41
+ # working it out and leadership revokes from the employee page.
42
+ revoke_access! if final_day <= Date.current
43
+
35
44
  Notifications.publish(
36
45
  "resignation.accepted",
37
46
  title: "Resignation accepted — last working day #{final_day.strftime('%d %b %Y')}",
@@ -56,8 +65,21 @@ module HrLite
56
65
  true
57
66
  end
58
67
 
68
+ # False when the person had no employee profile to stamp — see accept!.
69
+ def exit_date_recorded?
70
+ @exit_date_recorded
71
+ end
72
+
59
73
  private
60
74
 
75
+ # Best-effort, like every other call of this hook: a host that cannot
76
+ # revoke must not roll back an accepted resignation.
77
+ def revoke_access!
78
+ HrLite.config.offboard_user.call(user)
79
+ rescue => e
80
+ Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
81
+ end
82
+
61
83
  def notify_submitted
62
84
  Notifications.publish(
63
85
  "resignation.submitted",
@@ -78,6 +100,11 @@ module HrLite
78
100
  def single_open_resignation
79
101
  if self.class.pending.where(user_id: user_id).exists?
80
102
  errors.add(:base, "You already have a pending resignation")
103
+ elsif self.class.where(user_id: user_id, status: "accepted").exists?
104
+ # Only `pending` used to block a second one, so someone whose exit was
105
+ # already agreed could resign again — and accepting the new one would
106
+ # overwrite the exit date payroll and attendance already clip to.
107
+ errors.add(:base, "Your resignation has already been accepted — talk to your manager to change the date")
81
108
  end
82
109
  end
83
110
  end
@@ -18,6 +18,10 @@ module HrLite
18
18
  before_save :ensure_run_editable
19
19
 
20
20
  scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
21
+ # What counts as already paid for tax-to-date. A finalized run is money
22
+ # already decided; waiting for publication made the next month's TDS
23
+ # projection read those earnings as zero.
24
+ scope :settled, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: %w[finalized published] }) }
21
25
  scope :recent_first, -> { order(period_month: :desc) }
22
26
 
23
27
  def published?
@@ -33,13 +37,13 @@ module HrLite
33
37
  lop_override || lop_days
34
38
  end
35
39
 
36
- # Indian FY (Apr 1) to-date sums of PUBLISHED slips before this period —
40
+ # Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
37
41
  # feeds the TDS projector and the slip's YTD table.
38
42
  def self.fy_to_date(user, period_month)
39
43
  fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
40
44
  : Date.new(period_month.year - 1, 4, 1)
41
- slips = published.where(user_id: user.id)
42
- .where(period_month: fy_start...period_month)
45
+ slips = settled.where(user_id: user.id)
46
+ .where(period_month: fy_start...period_month)
43
47
  {
44
48
  gross: slips.sum(BigDecimal(0)) { |s| s.gross_earnings || BigDecimal(0) },
45
49
  tds: slips.sum(BigDecimal(0)) { |s| Money.d(s.deduction_amount("tds")) },
@@ -6,6 +6,8 @@ module HrLite
6
6
  WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
7
7
 
8
8
  validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
+ validates :employee_code_prefix, presence: true,
10
+ format: { with: /\A[A-Za-z]{1,10}\z/, message: "letters only, max 10" }
9
11
 
10
12
  def self.instance
11
13
  first_or_create!
@@ -4,6 +4,9 @@ module HrLite
4
4
  # without GPS or outside every office radius is recorded AND flagged —
5
5
  # denying GPS must not stop anyone from working.
6
6
  class AttendancePuncher
7
+ # What the browser can legitimately report (geo_punch.js).
8
+ GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
9
+
7
10
  Result = Struct.new(:record, :error, keyword_init: true) do
8
11
  def ok? = error.nil?
9
12
  end
@@ -18,7 +21,11 @@ module HrLite
18
21
  @lat = lat.presence
19
22
  @lng = lng.presence
20
23
  @accuracy_m = accuracy_m.presence
21
- @geo_status = geo_status
24
+ # Client-supplied and rendered straight into the admin-facing flag note.
25
+ # Anything outside the browser's real permission states is not evidence:
26
+ # a blank one (JS never ran) produced "without GPS ()", and a crafted one
27
+ # let an employee write reassuring text onto their own flag.
28
+ @geo_status = GEO_STATUSES.include?(geo_status.to_s) ? geo_status.to_s : "unavailable"
22
29
  end
23
30
 
24
31
  def call
@@ -1,9 +1,12 @@
1
1
  module HrLite
2
2
  module Calculators
3
- # ESI: eligibility is decided on the FULL structure monthly gross
4
- # (a low-attendance month must not pull someone into ESI); when
5
- # eligible, contributions apply to the earned gross and round UP
6
- # to the next rupee (ESIC rule).
3
+ # ESI: eligibility is decided on the FULL structure monthly gross (a
4
+ # low-attendance month must not pull someone into ESI), and specifically
5
+ # on the gross in force at the START of the ESIC contribution period —
6
+ # April–September or October–March. A rise past the ceiling mid-period
7
+ # does not end coverage; the caller resolves that period-start gross and
8
+ # passes it here. Contributions apply to the EARNED gross and round up to
9
+ # the next rupee (ESIC rule).
7
10
  module Esi
8
11
  Result = Struct.new(:applicable, :employee, :employer, keyword_init: true) do
9
12
  def applicable? = applicable
@@ -8,7 +8,11 @@ module HrLite
8
8
  slabs = rates[state.to_s] || []
9
9
  earned = Money.d(gross_earned)
10
10
 
11
- slab = slabs.reverse.find { |row| earned > row[:above] }
11
+ # `from:` is an INCLUSIVE lower bound ₹25,000 is taxed, ₹24,999.50 is
12
+ # not. The older exclusive `above:` form still works so a host's own
13
+ # slab table keeps functioning, but it cannot express that boundary on
14
+ # a decimal amount: `above: 24999` taxed a prorated ₹24,999.50.
15
+ slab = slabs.reverse.find { |row| row[:from] ? earned >= row[:from] : earned > row[:above] }
12
16
  return BigDecimal(0) unless slab
13
17
 
14
18
  amount = slab[:monthly]
@@ -35,7 +35,14 @@ module HrLite
35
35
  taxable = [ projected - deductions, BigDecimal(0) ].max
36
36
 
37
37
  slab_tax = slab_tax_for(taxable, table[:slabs])
38
- slab_tax = BigDecimal(0) if taxable <= table[:rebate_cap] # §87A full rebate
38
+ if taxable <= table[:rebate_cap]
39
+ slab_tax = BigDecimal(0) # §87A full rebate
40
+ elsif table[:marginal_relief]
41
+ # Just past the cliff the tax may not exceed the income earned above
42
+ # it — without this, one rupee over ₹12,00,000 costs ~₹60,000 of tax.
43
+ # Old regime carries no such relief, hence the per-regime flag.
44
+ slab_tax = [ slab_tax, taxable - table[:rebate_cap] ].min
45
+ end
39
46
  annual = Money.round_to_10(slab_tax * (1 + table[:cess_rate]))
40
47
 
41
48
  monthly = Money.round_rupee((annual - Money.d(fy_tds_paid)) / months_remaining)
@@ -3,19 +3,24 @@ module HrLite
3
3
  # company-wide holidays inside the range are free; a half-day (single-day
4
4
  # requests only) consumes 0.5.
5
5
  module LeaveDayCounter
6
- def self.count(leave_request)
6
+ # `calendar:` lets a caller counting many requests over the same span
7
+ # build ONE calendar and reuse it. Each one costs a Holiday query and a
8
+ # Setting query, and the balances grid counts every approved request of
9
+ # every employee for every leave type.
10
+ def self.count(leave_request, calendar: nil)
7
11
  count_range(
8
12
  start_date: leave_request.start_date,
9
13
  end_date: leave_request.end_date,
10
- half_day: leave_request.half_day
14
+ half_day: leave_request.half_day,
15
+ calendar: calendar
11
16
  )
12
17
  end
13
18
 
14
- def self.count_range(start_date:, end_date:, half_day: false)
19
+ def self.count_range(start_date:, end_date:, half_day: false, calendar: nil)
15
20
  return 0 if start_date.nil? || end_date.nil? || end_date < start_date
16
21
 
17
22
  range = start_date..end_date
18
- working = WorkingCalendar.new(range).working_days_in(range)
23
+ working = (calendar || WorkingCalendar.new(range)).working_days_in(range)
19
24
  return BigDecimal("0.5") if half_day && working.positive?
20
25
 
21
26
  BigDecimal(working)
@@ -15,6 +15,17 @@ module HrLite
15
15
  LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date)
16
16
  end
17
17
 
18
+ # Comp-off and regularization are approvals too. Counting leave alone made
19
+ # the board announce "All present and accounted for" while both queues
20
+ # still had work in them.
21
+ def pending_comp_offs
22
+ CompOffRequest.pending.includes(:user).recent_first
23
+ end
24
+
25
+ def pending_regularizations
26
+ RegularizationRequest.pending.includes(:user).recent_first
27
+ end
28
+
18
29
  def flagged_today
19
30
  AttendanceRecord.for_date(@date).flagged.includes(:user)
20
31
  end
@@ -25,7 +36,7 @@ module HrLite
25
36
 
26
37
  def kpis
27
38
  {
28
- pending: pending_requests.count,
39
+ pending: pending_requests.count + pending_comp_offs.count + pending_regularizations.count,
29
40
  on_leave: on_leave_today.count,
30
41
  flagged: flagged_today.count,
31
42
  missing_checkout: missing_checkout_yesterday.count
@@ -31,6 +31,11 @@ module HrLite
31
31
  run: @run, user: user, structure: structure, profile: profile,
32
32
  lop_override: slip.lop_override, tds_override: slip.tds_override
33
33
  )
34
+ if attributes[:total_deductions] > attributes[:gross_earnings]
35
+ warnings << "Deductions exceed earnings for #{HrLite.display_name(user)} — " \
36
+ "net pay floored at zero, review the overrides"
37
+ end
38
+
34
39
  slip.assign_attributes(attributes)
35
40
  slip.save!
36
41
  keep_ids << slip.id
@@ -21,8 +21,14 @@ module HrLite
21
21
  def call
22
22
  summary = AttendanceSummary.for(user: @user, month: @run.period_month)
23
23
  days_in_month = summary[:days_in_month]
24
- lop = Money.d(@lop_override || summary[:lop_days])
25
- payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) - lop, Money.d(0) ].max
24
+ # Clamped defensively: a stored negative override (written before the
25
+ # controller bounded them) would otherwise pay more days than the month has.
26
+ lop = [ Money.d(@lop_override || summary[:lop_days]), BigDecimal(0) ].max
27
+ # `upcoming` is normally zero — PayrollRun refuses a month that has not
28
+ # ended — but subtracting it means a legacy run for an open month cannot
29
+ # pay for days nobody has worked yet.
30
+ payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) -
31
+ Money.d(summary[:upcoming]) - lop, Money.d(0) ].max
26
32
 
27
33
  earnings = Calculators::Proration.call(
28
34
  structure: @structure, payable_days: payable, days_in_month: days_in_month
@@ -44,7 +50,7 @@ module HrLite
44
50
  )
45
51
  end
46
52
 
47
- esi = Calculators::Esi.call(monthly_gross: @structure.monthly_gross, gross_earned: gross_earned,
53
+ esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: gross_earned,
48
54
  applicable: @structure.esi_applicable, rates: @rates[:esi])
49
55
  if esi.applicable?
50
56
  deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
@@ -60,8 +66,12 @@ module HrLite
60
66
  regime: @profile.tax_regime,
61
67
  structure_monthly_gross: @structure.monthly_gross,
62
68
  gross_earned_this_month: gross_earned,
63
- fy_gross_paid: fy[:gross],
64
- fy_tds_paid: fy[:tds],
69
+ # Plus anything paid this FY that this install never ran — a previous
70
+ # employer, or the months before payroll was switched on. Without it a
71
+ # mid-year start projects those months as zero income and can land the
72
+ # whole year under the rebate cap.
73
+ fy_gross_paid: fy[:gross] + Money.d(@profile.fy_opening_gross),
74
+ fy_tds_paid: fy[:tds] + Money.d(@profile.fy_opening_tds),
65
75
  months_remaining: months_remaining_in_fy,
66
76
  declared_annual_deductions: @profile.declared_annual_deductions,
67
77
  rates: @rates[:income_tax],
@@ -75,6 +85,9 @@ module HrLite
75
85
  period_month: @run.period_month,
76
86
  days_in_month: days_in_month,
77
87
  payable_days: payable,
88
+ # Recorded so the slip can say WHY a mid-month joiner's payable days
89
+ # are short, instead of looking like days went missing.
90
+ out_of_window_days: Money.d(summary[:out_of_window]),
78
91
  lop_days: Money.d(summary[:lop_days]),
79
92
  lop_override: @lop_override,
80
93
  tds_override: @tds_override,
@@ -84,17 +97,50 @@ module HrLite
84
97
  tax_details: tds.details.to_json,
85
98
  gross_earnings: gross_earned,
86
99
  total_deductions: total_deductions,
87
- net_pay: gross_earned - total_deductions,
100
+ # A heavy-LOP month can leave TDS (projected from the full structure)
101
+ # larger than what was actually earned. Nobody is paid a negative
102
+ # salary; PayrollRunProcessor warns when this floor bites.
103
+ net_pay: [ gross_earned - total_deductions, BigDecimal(0) ].max,
88
104
  computed_at: Time.current
89
105
  }
90
106
  end
91
107
 
92
108
  private
93
109
 
94
- # Months left in the Indian FY including the run month itself.
110
+ # Months left in the Indian FY (Apr–Mar) including the run month itself:
111
+ # April = 12, December = 4, January = 3, March = 1. Capped at the exit
112
+ # month for a leaver, so a final settlement is not spread over months
113
+ # that will never be paid.
95
114
  def months_remaining_in_fy
96
115
  month = @run.period_month.month
97
- month >= 4 ? (15 - month) : (3 - month + 1)
116
+ calendar = month >= 4 ? (16 - month) : (4 - month)
117
+ exit_date = @profile&.date_of_exit
118
+ return calendar if exit_date.nil? || exit_date < @run.period_month
119
+
120
+ [ calendar, months_between(@run.period_month, exit_date) ].min
121
+ end
122
+
123
+ def months_between(from, to)
124
+ (to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
125
+ end
126
+
127
+ # ESIC contribution periods run April–September and October–March.
128
+ # Eligibility is fixed for the whole period, so it is decided on the
129
+ # salary in force on its first day — re-deciding it every month dropped
130
+ # someone out of ESI the moment a mid-period raise crossed the ceiling.
131
+ def esi_reference_gross
132
+ month = @run.period_month
133
+ start = if month.month.between?(4, 9)
134
+ Date.new(month.year, 4, 1)
135
+ elsif month.month >= 10
136
+ Date.new(month.year, 10, 1)
137
+ else
138
+ Date.new(month.year - 1, 10, 1)
139
+ end
140
+
141
+ # No structure that far back (a mid-period joiner) — their own is the
142
+ # only salary this period has ever had.
143
+ (SalaryStructure.effective_for(@user, start) || @structure).monthly_gross
98
144
  end
99
145
 
100
146
  def serialize_rows(rows)
@@ -14,7 +14,10 @@
14
14
 
15
15
  <div class="hrl-kpis">
16
16
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">Checked in</div></div>
17
- <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.size - @records.size %></div><div class="hrl-kpi__label">No punch</div></div>
17
+ <%# Derived from the same predicate as "Checked in". Counting rows instead
18
+ dropped a record with no check-in out of BOTH tiles, so the numbers
19
+ quietly stopped adding up to the headcount. %>
20
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @employees.size - @records.values.count { |r| r.check_in_at } %></div><div class="hrl-kpi__label">No punch</div></div>
18
21
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @flagged_count %></div><div class="hrl-kpi__label">Flagged</div></div>
19
22
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @records.values.count { |r| r.check_in_at && r.check_out_at.nil? } %></div><div class="hrl-kpi__label">Still in</div></div>
20
23
  </div>
@@ -29,18 +29,32 @@
29
29
  </section>
30
30
 
31
31
  <% if @request.pending? %>
32
+ <%# Approve and reject are two separate forms on purpose. Rails binds the
33
+ CSRF token to the form's OWN action, so one form retargeted with
34
+ `formaction:` can never pass verification on the second endpoint. %>
32
35
  <section class="hrl-card">
33
- <h2 class="hrl-card__title">Decide</h2>
36
+ <h2 class="hrl-card__title">Approve</h2>
34
37
  <%= form_with url: hr_lite.approve_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
35
38
  <div class="hrl-field">
36
- <%= f.label :decision_note, "Note (required to reject, optional to approve)" %>
37
- <%= f.text_area :decision_note %>
39
+ <%= f.label :decision_note, "Note (optional)", for: "approve_note" %>
40
+ <%= f.text_area :decision_note, id: "approve_note" %>
38
41
  </div>
39
42
  <div class="hrl-form-actions">
40
43
  <%= f.button "Approve — credit #{@request.credit_days.to_f} day",
41
44
  type: :submit, class: "hrl-btn hrl-btn--primary" %>
42
- <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger",
43
- formaction: hr_lite.reject_admin_comp_off_request_path(@request) %>
45
+ </div>
46
+ <% end %>
47
+ </section>
48
+
49
+ <section class="hrl-card">
50
+ <h2 class="hrl-card__title">Reject</h2>
51
+ <%= form_with url: hr_lite.reject_admin_comp_off_request_path(@request), method: :post, local: true do |f| %>
52
+ <div class="hrl-field">
53
+ <%= f.label :decision_note, "Reason (required)", for: "reject_note" %>
54
+ <%= f.text_area :decision_note, id: "reject_note" %>
55
+ </div>
56
+ <div class="hrl-form-actions">
57
+ <%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %>
44
58
  </div>
45
59
  <% end %>
46
60
  </section>