hr_lite 0.5.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -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 +16 -1
  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 +15 -8
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
  11. data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
  12. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +17 -2
  13. data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
  14. data/app/controllers/hr_lite/org_controller.rb +6 -1
  15. data/app/controllers/hr_lite/resignations_controller.rb +6 -1
  16. data/app/helpers/hr_lite/application_helper.rb +2 -12
  17. data/app/jobs/hr_lite/application_job.rb +5 -0
  18. data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
  19. data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
  20. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
  21. data/app/models/concerns/hr_lite/audited.rb +11 -4
  22. data/app/models/hr_lite/appraisal.rb +9 -1
  23. data/app/models/hr_lite/audit_log.rb +11 -0
  24. data/app/models/hr_lite/comp_off_request.rb +5 -15
  25. data/app/models/hr_lite/designation_change.rb +18 -1
  26. data/app/models/hr_lite/employee_profile.rb +5 -1
  27. data/app/models/hr_lite/leave_balance.rb +51 -5
  28. data/app/models/hr_lite/leave_request.rb +9 -1
  29. data/app/models/hr_lite/leave_type.rb +13 -3
  30. data/app/models/hr_lite/payroll_run.rb +39 -10
  31. data/app/models/hr_lite/regularization_request.rb +8 -0
  32. data/app/models/hr_lite/resignation.rb +27 -0
  33. data/app/models/hr_lite/salary_slip.rb +7 -3
  34. data/app/services/hr_lite/attendance_puncher.rb +8 -1
  35. data/app/services/hr_lite/calculators/esi.rb +7 -4
  36. data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
  37. data/app/services/hr_lite/calculators/tds.rb +8 -1
  38. data/app/services/hr_lite/leave_day_counter.rb +9 -4
  39. data/app/services/hr_lite/overview_query.rb +12 -1
  40. data/app/services/hr_lite/payroll_run_processor.rb +5 -0
  41. data/app/services/hr_lite/slip_builder.rb +54 -8
  42. data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
  43. data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
  44. data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
  45. data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
  46. data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
  47. data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
  48. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
  49. data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
  50. data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
  51. data/app/views/hr_lite/career/show.html.erb +7 -1
  52. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
  53. data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
  54. data/app/views/layouts/hr_lite/application.html.erb +42 -4
  55. data/config/routes.rb +1 -1
  56. data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
  57. data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
  58. data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
  59. data/lib/hr_lite/amount_in_words.rb +15 -2
  60. data/lib/hr_lite/money.rb +18 -0
  61. data/lib/hr_lite/notifications.rb +23 -8
  62. data/lib/hr_lite/statutory_rate_card.rb +6 -1
  63. data/lib/hr_lite/version.rb +1 -1
  64. data/lib/hr_lite.rb +5 -1
  65. metadata +5 -1
@@ -7,7 +7,9 @@ module HrLite
7
7
  class EmployeesController < LeadershipController
8
8
  def index
9
9
  @profiles = paginate(EmployeeProfile.includes(:user).order(:employee_code))
10
- @users_without_profile = HrLite.employees.reject { |u| EmployeeProfile.exists?(user_id: u.id) }
10
+ # One pluck instead of an EXISTS query per employee.
11
+ profiled = EmployeeProfile.pluck(:user_id).to_set
12
+ @users_without_profile = HrLite.employees.reject { |u| profiled.include?(u.id) }
11
13
  @pending_resignations = Resignation.pending.includes(:user).recent_first
12
14
  end
13
15
 
@@ -24,17 +26,26 @@ module HrLite
24
26
  def create
25
27
  new_login = params.dig(:employee_profile, :new_user_email).present?
26
28
  @profile = EmployeeProfile.new(profile_params)
29
+ saved = false
27
30
 
28
- if new_login
29
- user = HrLite.config.onboard_user.call(
30
- name: params.dig(:employee_profile, :new_user_name).to_s,
31
- email: params.dig(:employee_profile, :new_user_email).to_s.strip,
32
- password: params.dig(:employee_profile, :new_user_password).to_s
33
- )
34
- @profile.user_id = user.id
31
+ # One transaction: a profile that fails validation used to leave the
32
+ # freshly-created login committed — a role-bearing account with no HR
33
+ # profile, invisible on every HR screen.
34
+ ActiveRecord::Base.transaction do
35
+ if new_login
36
+ user = HrLite.config.onboard_user.call(
37
+ name: params.dig(:employee_profile, :new_user_name).to_s,
38
+ email: params.dig(:employee_profile, :new_user_email).to_s.strip,
39
+ password: params.dig(:employee_profile, :new_user_password).to_s
40
+ )
41
+ @profile.user_id = user.id
42
+ end
43
+
44
+ saved = @profile.save
45
+ raise ActiveRecord::Rollback unless saved
35
46
  end
36
47
 
37
- if @profile.save
48
+ if saved
38
49
  if new_login
39
50
  invite_url = HrLite.config.invite_url_for&.call(@profile.user)
40
51
  Notifications.publish(
@@ -66,7 +77,7 @@ module HrLite
66
77
 
67
78
  def update
68
79
  @profile = EmployeeProfile.find(params[:id])
69
- if @profile.update(profile_params)
80
+ if @profile.update(update_profile_params)
70
81
  redirect_to admin_employee_path(@profile), notice: "Employee profile updated."
71
82
  else
72
83
  render :edit, status: :unprocessable_entity
@@ -77,7 +88,9 @@ module HrLite
77
88
  # access via the host hook. Audited via the profile update.
78
89
  def offboard
79
90
  @profile = EmployeeProfile.find(params[:id])
80
- exit_date = params[:date_of_exit].presence&.to_date || Date.current
91
+ # `.to_date` on a raw param raises Date::Error and 500s; the
92
+ # controller's own parser exists for exactly this.
93
+ exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
81
94
 
82
95
  @profile.update!(date_of_exit: exit_date)
83
96
  begin
@@ -87,6 +100,8 @@ module HrLite
87
100
  end
88
101
  redirect_to admin_employee_path(@profile),
89
102
  notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
103
+ rescue ActiveRecord::RecordInvalid => e
104
+ redirect_to admin_employee_path(@profile), alert: e.record.errors.full_messages.to_sentence
90
105
  end
91
106
 
92
107
  private
@@ -95,9 +110,18 @@ module HrLite
95
110
  params.require(:employee_profile).permit(
96
111
  :user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
97
112
  :date_of_exit, :department, :work_location, :pan_number, :pf_uan, :esi_number,
98
- :bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions
113
+ :bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
114
+ :fy_opening_gross, :fy_opening_tds
99
115
  )
100
116
  end
117
+
118
+ # user_id says WHOSE profile this is, and salary structures, appraisals
119
+ # and slips all key off it independently. Repointing an existing profile
120
+ # hands one person's PAN, bank details and pay history to another, so it
121
+ # is a create-time field only.
122
+ def update_profile_params
123
+ profile_params.except(:user_id)
124
+ end
101
125
  end
102
126
  end
103
127
  end
@@ -2,8 +2,7 @@ module HrLite
2
2
  module Admin
3
3
  class LeaveBalancesController < BaseController
4
4
  def index
5
- year = params[:year].to_i
6
- @year = year.between?(2000, 2100) ? year : LeaveYear.current_key
5
+ @year = sanitized_year
7
6
  @types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
8
7
  @employees = HrLite.employees
9
8
  end
@@ -12,7 +11,9 @@ module HrLite
12
11
  def adjust
13
12
  user = HrLite.user_klass.find(params[:user_id])
14
13
  type = LeaveType.find(params[:leave_type_id])
15
- year = params[:year].to_i
14
+ # Unsanitised, a missing param wrote the adjustment into leave year 0,
15
+ # where no screen can ever show it.
16
+ year = sanitized_year
16
17
  delta = BigDecimal(params[:delta].to_s)
17
18
  note = params[:note].to_s.strip
18
19
 
@@ -20,10 +21,7 @@ module HrLite
20
21
  return redirect_to admin_leave_balances_path(year: year), alert: "A note is required."
21
22
  end
22
23
 
23
- balance = LeaveBalance.for(user, type, year)
24
- balance.adjustment += delta
25
- balance.adjustment_note = [ balance.adjustment_note.presence, "#{delta.to_f} — #{note}" ].compact.join("; ")
26
- balance.save!
24
+ balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
27
25
 
28
26
  AuditLog.create!(
29
27
  actor: hr_current_user, action: "adjust",
@@ -34,7 +32,16 @@ module HrLite
34
32
  redirect_to admin_leave_balances_path(year: year),
35
33
  notice: "Balance adjusted for #{HrLite.display_name(user)}."
36
34
  rescue ArgumentError
37
- redirect_to admin_leave_balances_path, alert: "Enter a valid adjustment number."
35
+ # Keep the admin on the year they were looking at.
36
+ redirect_to admin_leave_balances_path(year: sanitized_year),
37
+ alert: "Enter a valid adjustment number."
38
+ end
39
+
40
+ private
41
+
42
+ def sanitized_year
43
+ year = params[:year].to_i
44
+ year.between?(2000, 2100) ? year : LeaveYear.current_key
38
45
  end
39
46
  end
40
47
  end
@@ -24,6 +24,22 @@ module HrLite
24
24
  redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
25
25
  end
26
26
 
27
+ # LeaveRequest#cancellable_by? has always allowed an admin to call off
28
+ # approved future leave, but no route reached it — the only cancel action
29
+ # was the employee's own, scoped to their own rows. A trip called off
30
+ # while the person was unreachable, or after they were offboarded, could
31
+ # not be released at all, and the quota stayed spent.
32
+ def cancel
33
+ request = LeaveRequest.find(params[:id])
34
+ if request.cancellable_by?(hr_current_user)
35
+ request.cancel!(actor: hr_current_user)
36
+ redirect_to admin_leave_requests_path, notice: "Leave cancelled — the balance is released."
37
+ else
38
+ redirect_to admin_leave_request_path(request),
39
+ alert: "Only pending leave, or approved leave that has not started, can be cancelled."
40
+ end
41
+ end
42
+
27
43
  def reject
28
44
  request = LeaveRequest.find(params[:id])
29
45
  note = params[:decision_note].to_s.strip
@@ -5,13 +5,25 @@ module HrLite
5
5
  resignation = Resignation.find(params[:id])
6
6
  resignation.accept!(
7
7
  actor: hr_current_user,
8
- last_day: params[:last_day].presence&.to_date,
8
+ # `.to_date` on a raw param raises Date::Error and 500s; the
9
+ # controller's own parser exists for exactly this.
10
+ last_day: params[:last_day].presence && parse_date_param(params[:last_day]),
9
11
  note: params[:note]
10
12
  )
11
- redirect_to admin_employees_path,
12
- notice: "Resignation accepted — exit date recorded on the profile."
13
- rescue ActiveRecord::RecordInvalid
14
- redirect_to admin_employees_path, alert: "Only pending resignations can be accepted."
13
+ notice = if resignation.exit_date_recorded?
14
+ "Resignation accepted — exit date recorded on the profile."
15
+ else
16
+ "Resignation accepted. No employee profile exists for " \
17
+ "#{hr_display_name(resignation.user)}, so no exit date was recorded — " \
18
+ "create the profile to clip attendance and payroll."
19
+ end
20
+ redirect_to admin_employees_path, notice: notice
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ # A rejected exit date used to be reported as "not pending", which was
23
+ # simply the wrong reason: the transaction rolls back either way.
24
+ message = resignation.reload.pending? ? e.record.errors.full_messages.to_sentence
25
+ : "Only pending resignations can be accepted."
26
+ redirect_to admin_employees_path, alert: message
15
27
  end
16
28
  end
17
29
  end
@@ -20,11 +20,26 @@ module HrLite
20
20
  tds_override = params.dig(:salary_slip, :tds_override).presence
21
21
  profile = EmployeeProfile.find_by(user_id: slip.user_id)
22
22
  structure = SalaryStructure.effective_for(slip.user, run.period_month)
23
+ if structure.nil?
24
+ return redirect_to admin_salary_slip_path(slip),
25
+ alert: "No salary structure is effective for #{run.label} — add one first."
26
+ end
27
+
28
+ lop = lop_override && BigDecimal(lop_override)
29
+ tds = tds_override && BigDecimal(tds_override)
30
+ # Unbounded overrides are a money bug, not a typo: a negative LOP pays
31
+ # for days never worked, one above the month zeroes the person out.
32
+ if lop && !lop.between?(0, slip.days_in_month)
33
+ return redirect_to admin_salary_slip_path(slip),
34
+ alert: "LOP days must be between 0 and #{slip.days_in_month}."
35
+ end
36
+ if tds&.negative?
37
+ return redirect_to admin_salary_slip_path(slip), alert: "TDS cannot be negative."
38
+ end
23
39
 
24
40
  attributes = SlipBuilder.call(
25
41
  run: run, user: slip.user, structure: structure, profile: profile,
26
- lop_override: lop_override && BigDecimal(lop_override),
27
- tds_override: tds_override && BigDecimal(tds_override)
42
+ lop_override: lop, tds_override: tds
28
43
  )
29
44
  slip.update!(attributes)
30
45
 
@@ -3,7 +3,12 @@ module HrLite
3
3
  # Money tier: salary structures, payroll, slips, appraisals and
4
4
  # promotions. Only config.superadmin_emails — ordinary leadership can
5
5
  # govern policy and people but never sees another person's pay.
6
+ # The money tier stands on its own rather than layering on leadership: a
7
+ # payroll operator does not have to also govern people and policy.
8
+ # Inheriting the leadership check locked a configured superadmin who was
9
+ # absent from leadership_emails out of payroll entirely.
6
10
  class SuperadminController < LeadershipController
11
+ skip_before_action :require_hr_leadership!
7
12
  before_action :require_hr_superadmin!
8
13
 
9
14
  private
@@ -10,8 +10,13 @@ module HrLite
10
10
  @by_user = profiles.index_by(&:user_id)
11
11
  build_tree(profiles)
12
12
  @own_profile = @by_user[hr_current_user.id]
13
+ # STOPS at the first exited manager rather than skipping past them. The
14
+ # L-labels are positional, so dropping a link from the middle promoted
15
+ # everyone above it — the card then called a skip-level manager your L1,
16
+ # contradicting the tree below, which drops exited managers entirely and
17
+ # renders their reports as roots.
13
18
  exited = EmployeeProfile.where(date_of_exit: ...Date.current).pluck(:user_id).to_set
14
- @own_chain = (@own_profile&.reporting_chain || []).reject { |boss| exited.include?(boss.id) }
19
+ @own_chain = (@own_profile&.reporting_chain || []).take_while { |boss| !exited.include?(boss.id) }
15
20
  end
16
21
 
17
22
  private
@@ -4,7 +4,12 @@ module HrLite
4
4
  class ResignationsController < ApplicationController
5
5
  def show
6
6
  @resignation = own.recent_first.first
7
- @new_resignation = Resignation.new(proposed_last_day: Date.current + 30) unless @resignation&.pending?
7
+ # No form once one is pending OR already accepted: an agreed exit date is
8
+ # not something to re-file, and accepting a second one would overwrite
9
+ # the date payroll and attendance already clip to.
10
+ unless @resignation&.pending? || @resignation&.accepted?
11
+ @new_resignation = Resignation.new(proposed_last_day: Date.current + 30)
12
+ end
8
13
  end
9
14
 
10
15
  def create
@@ -71,19 +71,9 @@ module HrLite
71
71
  render "hr_lite/shared/pagination"
72
72
  end
73
73
 
74
+ # Delegates to the PORO so the host-rendered slip PDF formats identically.
74
75
  def hrl_money(amount)
75
- return "—" if amount.nil?
76
-
77
- whole, fraction = HrLite::Money.round2(amount).to_s("F").split(".")
78
- sign = whole.start_with?("-") ? "-" : ""
79
- whole = whole.delete_prefix("-")
80
- # Indian digit grouping: last three digits together, then pairs
81
- # (12,34,567 — not the western 1,234,567).
82
- if whole.length > 3
83
- head = whole[0..-4].reverse.scan(/\d{1,2}/).join(",").reverse
84
- whole = "#{head},#{whole[-3..]}"
85
- end
86
- "#{sign}#{HrLite.config.currency_symbol}#{whole}.#{fraction.ljust(2, '0')}"
76
+ HrLite::Money.format(amount)
87
77
  end
88
78
 
89
79
  # Indian-system amount in words for the slip footer. Delegates to the
@@ -1,4 +1,9 @@
1
1
  module HrLite
2
+ # Parent of the engine's jobs. It existed but nothing inherited from it, so
3
+ # no retry policy applied anywhere: a digest or a rollover that hit a
4
+ # dropped connection was simply lost for that day — or for that year.
2
5
  class ApplicationJob < ActiveJob::Base
6
+ retry_on ActiveRecord::ConnectionNotEstablished, ActiveRecord::Deadlocked,
7
+ wait: :polynomially_longer, attempts: 5
3
8
  end
4
9
  end
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Morning leadership email: who's out, what's pending, what's flagged.
3
3
  # Sends nothing on a quiet day.
4
- class DailyDigestJob < ActiveJob::Base
4
+ class DailyDigestJob < ApplicationJob
5
5
  queue_as :default
6
6
 
7
7
  def perform(date: Date.current)
@@ -4,7 +4,7 @@ module HrLite
4
4
  # carry-forward into the new year's balance rows. Idempotent — a balance
5
5
  # whose carry has already been written is never touched again (manual
6
6
  # adjustments stay safe).
7
- class LeaveYearRolloverJob < ActiveJob::Base
7
+ class LeaveYearRolloverJob < ApplicationJob
8
8
  queue_as :default
9
9
 
10
10
  def perform(year: nil)
@@ -3,14 +3,17 @@ module HrLite
3
3
  # payroll from attendance and the policy, then tell leadership it is
4
4
  # waiting for review. Publishing stays a deliberate human action —
5
5
  # the system prepares, people approve.
6
- class PayrollAutoDraftJob < ActiveJob::Base
6
+ class PayrollAutoDraftJob < ApplicationJob
7
7
  queue_as :default
8
8
 
9
9
  def perform(month: Date.current.prev_month.beginning_of_month)
10
10
  return unless EmployeeProfile.active_for(month).exists?
11
11
 
12
12
  run = PayrollRun.find_or_create_by!(period_month: month)
13
- return unless run.draft? || run.review?
13
+ # Only ever drafts an UNTOUCHED run. Accepting `review` as well meant a
14
+ # re-run recomputed a month somebody was already reviewing and told
15
+ # leadership all over again that it was waiting for them.
16
+ return unless run.draft? && run.salary_slips.none?
14
17
 
15
18
  run.compute!(actor: nil)
16
19
  Notifications.publish(
@@ -9,10 +9,14 @@ module HrLite
9
9
  SKIPPED_ATTRIBUTES = %w[updated_at created_at].freeze
10
10
  REDACTED = "[changed]".freeze
11
11
 
12
+ # *_commit, not *_create/_update/_destroy: these callbacks write an audit
13
+ # row and email leadership a diff. Fired inside the transaction they
14
+ # announce changes that then roll back — a failed onboarding used to mail
15
+ # out a profile diff for a profile that was never saved.
12
16
  included do
13
- after_create { hr_lite_audit!("create") }
14
- after_update { hr_lite_audit!("update") }
15
- after_destroy { hr_lite_audit!("destroy") }
17
+ after_create_commit { hr_lite_audit!("create") }
18
+ after_update_commit { hr_lite_audit!("update") }
19
+ after_destroy_commit { hr_lite_audit!("destroy") }
16
20
  end
17
21
 
18
22
  private
@@ -63,7 +67,10 @@ module HrLite
63
67
  "policy.changed",
64
68
  title: "#{actor_name.presence || 'Someone'} #{log.action}d #{subject}",
65
69
  body: nil,
66
- diff: log.audited_changes
70
+ # policy.changed fans out to leadership_emails, which is a WIDER list
71
+ # than the money tier. Leadership may know an appraisal changed; the
72
+ # ratings and review text inside it are not theirs to read.
73
+ diff: log.money_tier? ? nil : log.audited_changes
67
74
  )
68
75
  end
69
76
  end
@@ -43,7 +43,15 @@ module HrLite
43
43
  body: rating ? "Rating: #{rating}/5" : nil,
44
44
  path: "/appraisals/#{id}",
45
45
  bell_to: [ user ],
46
- email_to: [ user ]
46
+ email_to: [ user ],
47
+ # Leadership gets the fact, named, pointing somewhere they can open —
48
+ # but not the rating: appraisal content is money-tier and this list is
49
+ # wider than that tier.
50
+ leadership: {
51
+ title: "#{HrLite.display_name(user)}'s appraisal for #{period_label} was shared",
52
+ body: nil,
53
+ path: "/admin/employees"
54
+ }
47
55
  )
48
56
  true
49
57
  end
@@ -8,7 +8,18 @@ module HrLite
8
8
 
9
9
  validates :action, :subject_type, :subject_id, presence: true
10
10
 
11
+ # Subjects whose CONTENTS belong to the money tier. Salary structures
12
+ # encrypt their amounts so the diff is already redacted, but appraisal
13
+ # ratings and review text are plain columns — they must not reach the
14
+ # leadership audit screen or the policy.changed email.
15
+ MONEY_TIER_TYPES = %w[HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure].freeze
16
+
11
17
  scope :recent, -> { order(created_at: :desc) }
18
+ scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
19
+
20
+ def money_tier?
21
+ MONEY_TIER_TYPES.include?(subject_type)
22
+ end
12
23
 
13
24
  def readonly?
14
25
  persisted?
@@ -106,21 +106,11 @@ module HrLite
106
106
  # balance. The balance row is locked for the increment; two admins
107
107
  # approving two different requests for one person cannot lose an update.
108
108
  def credit_balance!(type)
109
- year = LeaveYear.current_key
110
- balance = LeaveBalance.for(user, type, year)
111
- if balance.new_record?
112
- begin
113
- balance.save!
114
- rescue ActiveRecord::RecordNotUnique
115
- balance = LeaveBalance.for(user, type, year)
116
- end
117
- end
118
- balance.with_lock do
119
- balance.adjustment += credit_days
120
- balance.adjustment_note = [ balance.adjustment_note.presence,
121
- "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
122
- balance.save!
123
- end
109
+ LeaveBalance.adjust!(
110
+ user, type, LeaveYear.current_key,
111
+ delta: credit_days,
112
+ note: "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})"
113
+ )
124
114
  end
125
115
 
126
116
  def transition!(new_status, actor, note)
@@ -27,7 +27,17 @@ module HrLite
27
27
  self.from_designation ||= EmployeeProfile.find_by(user_id: user_id)&.designation
28
28
  end
29
29
 
30
+ # Only the newest row owns the profile's designation. Backfilling an older
31
+ # promotion used to overwrite the current title and push the stale one to
32
+ # the host as well. (A future-dated promotion still applies on save — that
33
+ # is how sharing an appraisal announces the new role.)
34
+ def latest_by_effective_date?
35
+ self.class.where(user_id: user_id).timeline.first&.id == id
36
+ end
37
+
30
38
  def apply_to_profile
39
+ return unless latest_by_effective_date?
40
+
31
41
  profile = EmployeeProfile.find_by(user_id: user_id)
32
42
  profile&.update!(designation: to_designation)
33
43
 
@@ -45,7 +55,14 @@ module HrLite
45
55
  body: "Effective #{effective_date.strftime('%d %b %Y')}.",
46
56
  path: "/career",
47
57
  bell_to: [ user ],
48
- email_to: [ user ]
58
+ email_to: [ user ],
59
+ # "/career" is the reader's OWN career page, so a leadership copy of
60
+ # this used to link them to their own record instead of the person the
61
+ # promotion is about.
62
+ leadership: {
63
+ title: "#{HrLite.display_name(user)} — new role: #{to_designation}",
64
+ path: "/admin/employees"
65
+ }
49
66
  )
50
67
  end
51
68
  end
@@ -13,7 +13,11 @@ module HrLite
13
13
  belongs_to :manager, class_name: HrLite.config.user_class, optional: true
14
14
 
15
15
  encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
16
- encrypted_money :declared_annual_deductions
16
+ # fy_opening_*: income already paid this financial year that this install
17
+ # did not run — a previous employer, or the months before payroll was
18
+ # switched on here. Feeds the TDS projection so a mid-year start does not
19
+ # read as zero income for the months it cannot see.
20
+ encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
17
21
 
18
22
  TAX_REGIMES = %w[new old].freeze
19
23
 
@@ -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