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
@@ -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,21 +21,26 @@ 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
- AuditLog.create!(
29
- actor: hr_current_user, action: "adjust",
30
- subject_type: balance.class.name, subject_id: balance.id,
31
- audited_changes: { "user" => HrLite.display_name(user), "type" => type.code,
32
- "delta" => delta.to_f, "note" => note }
26
+ AuditLog.record!(
27
+ action: "adjust", subject: balance, actor: hr_current_user,
28
+ changes: { "user" => HrLite.display_name(user), "type" => type.code,
29
+ "delta" => delta.to_f, "note" => note }
33
30
  )
34
31
  redirect_to admin_leave_balances_path(year: year),
35
32
  notice: "Balance adjusted for #{HrLite.display_name(user)}."
36
33
  rescue ArgumentError
37
- redirect_to admin_leave_balances_path, alert: "Enter a valid adjustment number."
34
+ # Keep the admin on the year they were looking at.
35
+ redirect_to admin_leave_balances_path(year: sanitized_year),
36
+ alert: "Enter a valid adjustment number."
37
+ end
38
+
39
+ private
40
+
41
+ def sanitized_year
42
+ year = params[:year].to_i
43
+ year.between?(2000, 2100) ? year : LeaveYear.current_key
38
44
  end
39
45
  end
40
46
  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
@@ -55,8 +55,17 @@ module HrLite
55
55
  # deliberately leadership-only PII.
56
56
  def register
57
57
  run = PayrollRun.find(params[:id])
58
- send_data register_csv(run), filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
59
- type: "text/csv"
58
+ csv = register_csv(run)
59
+ # A GET that walks out of the building with every employee's pay and
60
+ # bank account. Nothing else in the module leaks this much in one
61
+ # click, so the download itself is the auditable event.
62
+ AuditLog.record!(
63
+ action: "payroll.register_exported", subject: run, actor: hr_current_user,
64
+ changes: { "period" => run.label, "rows" => run.salary_slips.count,
65
+ "includes_bank_details" => true }
66
+ )
67
+ send_data csv, filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
68
+ type: "text/csv"
60
69
  end
61
70
 
62
71
  private
@@ -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,20 +20,34 @@ 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
 
31
- AuditLog.create!(
32
- actor: hr_current_user, action: "override",
33
- subject_type: slip.class.name, subject_id: slip.id,
34
- audited_changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
35
- "lop_override" => lop_override || "cleared",
36
- "tds_override" => tds_override ? "[changed]" : "cleared" }
46
+ AuditLog.record!(
47
+ action: "override", subject: slip, actor: hr_current_user,
48
+ changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
49
+ "lop_override" => lop_override || "cleared",
50
+ "tds_override" => tds_override ? "[changed]" : "cleared" }
37
51
  )
38
52
  redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
39
53
  rescue ArgumentError
@@ -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,43 @@ 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[
16
+ HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
+ HrLite::PayrollRun HrLite::SalarySlip
18
+ ].freeze
19
+
11
20
  scope :recent, -> { order(created_at: :desc) }
21
+ scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
22
+
23
+ # The five-line `create!` that four call sites had each spelled out.
24
+ #
25
+ # This RAISES, unlike the Audited concern, which swallows failures so an
26
+ # audit hiccup cannot roll back an ordinary policy edit. On the money
27
+ # path that trade goes the other way: a payroll transition nobody can
28
+ # explain afterwards should not be allowed to happen at all, so callers
29
+ # run it inside the same transaction as the write it describes.
30
+ #
31
+ # `changes` is a whitelist the caller writes out by hand. Never pass
32
+ # amounts — this table is not encrypted.
33
+ def self.record!(action:, subject:, actor: HrLite::Current.actor, changes: {})
34
+ create!(
35
+ actor: actor,
36
+ action: action,
37
+ subject_type: subject.class.name,
38
+ # A destroyed record has no id left, and the row still has to say
39
+ # what happened.
40
+ subject_id: subject.id || 0,
41
+ audited_changes: changes
42
+ )
43
+ end
44
+
45
+ def money_tier?
46
+ MONEY_TIER_TYPES.include?(subject_type)
47
+ end
12
48
 
13
49
  def readonly?
14
50
  persisted?
@@ -54,7 +54,9 @@ module HrLite
54
54
 
55
55
  Notifications.publish(
56
56
  "comp_off.approved",
57
- title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
57
+ # credit_days is 0.5 or 1 and nothing else, so there is no plural to
58
+ # reach for here.
59
+ title: "Comp-off approved — #{credit_days.to_f} day credited for #{date_worked.strftime('%d %b')}",
58
60
  body: decision_note.presence,
59
61
  path: "/comp_off_requests",
60
62
  bell_to: [ user ], email_to: [ user ]
@@ -106,21 +108,11 @@ module HrLite
106
108
  # balance. The balance row is locked for the increment; two admins
107
109
  # approving two different requests for one person cannot lose an update.
108
110
  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
111
+ LeaveBalance.adjust!(
112
+ user, type, LeaveYear.current_key,
113
+ delta: credit_days,
114
+ note: "+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})"
115
+ )
124
116
  end
125
117
 
126
118
  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