hr_lite 0.5.0 → 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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -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/attendances_controller.rb +19 -5
  7. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
  8. data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
  9. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +19 -13
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  11. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  12. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  13. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +22 -8
  14. data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
  15. data/app/controllers/hr_lite/org_controller.rb +6 -1
  16. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  17. data/app/helpers/hr_lite/application_helper.rb +2 -12
  18. data/app/jobs/hr_lite/application_job.rb +5 -0
  19. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  20. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  21. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  22. data/app/models/concerns/hr_lite/audited.rb +11 -4
  23. data/app/models/hr_lite/appraisal.rb +9 -1
  24. data/app/models/hr_lite/audit_log.rb +36 -0
  25. data/app/models/hr_lite/comp_off_request.rb +8 -16
  26. data/app/models/hr_lite/designation_change.rb +18 -1
  27. data/app/models/hr_lite/employee_profile.rb +5 -1
  28. data/app/models/hr_lite/leave_balance.rb +51 -5
  29. data/app/models/hr_lite/leave_request.rb +36 -5
  30. data/app/models/hr_lite/leave_type.rb +13 -3
  31. data/app/models/hr_lite/payroll_run.rb +68 -13
  32. data/app/models/hr_lite/regularization_request.rb +11 -4
  33. data/app/models/hr_lite/resignation.rb +27 -0
  34. data/app/models/hr_lite/salary_slip.rb +8 -5
  35. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  36. data/app/services/hr_lite/calculators/esi.rb +7 -4
  37. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  38. data/app/services/hr_lite/calculators/tds.rb +8 -1
  39. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  40. data/app/services/hr_lite/overview_query.rb +12 -1
  41. data/app/services/hr_lite/payroll_run_processor.rb +9 -1
  42. data/app/services/hr_lite/slip_builder.rb +54 -8
  43. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  44. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  45. data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
  46. data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
  47. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  48. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  49. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  50. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  51. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  52. data/app/views/hr_lite/career/show.html.erb +7 -1
  53. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  54. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  55. data/app/views/layouts/hr_lite/application.html.erb +42 -4
  56. data/config/routes.rb +1 -1
  57. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  58. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  59. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  60. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  61. data/lib/hr_lite/amount_in_words.rb +15 -2
  62. data/lib/hr_lite/configuration.rb +3 -6
  63. data/lib/hr_lite/financial_year.rb +26 -0
  64. data/lib/hr_lite/money.rb +18 -0
  65. data/lib/hr_lite/notifications.rb +23 -8
  66. data/lib/hr_lite/statutory_rate_card.rb +51 -6
  67. data/lib/hr_lite/version.rb +1 -1
  68. data/lib/hr_lite.rb +23 -2
  69. 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
@@ -73,10 +78,15 @@ module HrLite
73
78
 
74
79
  def cancel!(actor:)
75
80
  was_approved = approved?
76
- self.status = "cancelled"
77
- self.decided_by_id = actor.id
78
- self.decided_at = Time.current
79
- 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
80
90
 
81
91
  Notifications.publish(
82
92
  "leave.cancelled",
@@ -112,15 +122,36 @@ module HrLite
112
122
  self.decided_at = Time.current
113
123
  self.decision_note = note
114
124
  save!
125
+ audit!(new_status, actor, note)
115
126
  end
116
127
  end
117
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
+
118
146
  # This request is still pending here, so balance.used excludes it:
119
147
  # approving is valid iff the request fits the remaining balance.
120
148
  def insufficient_balance_now?
121
149
  return false if leave_type.unlimited?
122
150
 
123
- LeaveDayCounter.count(self) > balance.available
151
+ # Same as-of date the create-time check used. Reading it as of TODAY
152
+ # meant a request validated against the balance it would have on the
153
+ # leave dates could never be approved on a monthly-accrual type.
154
+ LeaveDayCounter.count(self) > balance.available(as_of: start_date)
124
155
  end
125
156
 
126
157
  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,23 +29,42 @@ 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
+ 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
48
+ true
49
+ rescue => e
50
+ # Restore what the run WAS. Hardcoding "draft" here used to demote a
51
+ # finalized or published run — which then exposed the delete-draft
52
+ # control, and that cascades over every salary slip.
53
+ update_columns(status: previous) # rubocop:disable Rails/SkipsModelValidations
54
+ raise e
55
+ end
40
56
  end
41
57
 
42
58
  def finalize!(actor:)
43
59
  raise_unless %w[review]
44
60
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
45
61
 
46
- 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
47
68
  Notifications.publish(
48
69
  "payroll.finalized",
49
70
  title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
@@ -54,21 +75,37 @@ module HrLite
54
75
 
55
76
  def unlock!(actor:)
56
77
  raise_unless %w[finalized]
57
- 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
58
84
  true
59
85
  end
60
86
 
61
87
  def publish!(actor:)
62
88
  raise_unless %w[finalized]
63
- 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
64
93
 
65
94
  slips = salary_slips.includes(:user).to_a
95
+ # Everyone is emailed — a final settlement matters most to the person who
96
+ # has left — but a bell is only useful to someone who can still sign in,
97
+ # and offboarding revokes that. Sending one pointed at a page they cannot
98
+ # open is just noise in a place they will never look.
99
+ exited = EmployeeProfile.where(user_id: slips.map(&:user_id))
100
+ .where(date_of_exit: ..Date.current)
101
+ .pluck(:user_id).to_set
102
+
66
103
  Notifications.publish(
67
104
  "payroll.published",
68
105
  title: "Your salary slip for #{label} is ready",
69
106
  body: "Open Earthly HR to view or download it.",
70
107
  path: "/salary_slips",
71
- bell_to: slips.map(&:user),
108
+ bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
72
109
  email_to: slips.map(&:user)
73
110
  )
74
111
  true
@@ -87,6 +124,14 @@ module HrLite
87
124
 
88
125
  private
89
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
+
90
135
  def sum_slips(attribute)
91
136
  salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
92
137
  end
@@ -102,6 +147,16 @@ module HrLite
102
147
  errors.add(:period_month, "must be the 1st of a month")
103
148
  end
104
149
 
150
+ # Days that have not happened yet score as `upcoming`, which payroll folds
151
+ # into payable — so computing a month before it ends pays for days nobody
152
+ # worked, and once finalized the slips are immutable.
153
+ def period_is_a_closed_month
154
+ return unless period_month
155
+ return if period_month.end_of_month < Date.current
156
+
157
+ errors.add(:period_month, "must be a month that has already ended")
158
+ end
159
+
105
160
  def draft_only_destroy
106
161
  return if draft?
107
162
 
@@ -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
@@ -63,10 +71,9 @@ module HrLite
63
71
  raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
64
72
 
65
73
  record.save!
66
- AuditLog.create!(
67
- actor: actor, action: "regularize",
68
- subject_type: record.class.name, subject_id: record.id,
69
- 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 }
70
77
  )
71
78
  end
72
79
 
@@ -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,12 @@ 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
- fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
40
- : Date.new(period_month.year - 1, 4, 1)
41
- slips = published.where(user_id: user.id)
42
- .where(period_month: fy_start...period_month)
43
+ fy_start = FinancialYear.start_for(period_month)
44
+ slips = settled.where(user_id: user.id)
45
+ .where(period_month: fy_start...period_month)
43
46
  {
44
47
  gross: slips.sum(BigDecimal(0)) { |s| s.gross_earnings || BigDecimal(0) },
45
48
  tds: slips.sum(BigDecimal(0)) { |s| Money.d(s.deduction_amount("tds")) },
@@ -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
@@ -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
@@ -31,6 +34,11 @@ module HrLite
31
34
  run: @run, user: user, structure: structure, profile: profile,
32
35
  lop_override: slip.lop_override, tds_override: slip.tds_override
33
36
  )
37
+ if attributes[:total_deductions] > attributes[:gross_earnings]
38
+ warnings << "Deductions exceed earnings for #{HrLite.display_name(user)} — " \
39
+ "net pay floored at zero, review the overrides"
40
+ end
41
+
34
42
  slip.assign_attributes(attributes)
35
43
  slip.save!
36
44
  keep_ids << slip.id