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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +218 -1
- data/README.md +312 -99
- data/app/assets/javascripts/hr_lite/confirm.js +31 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +28 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +19 -5
- data/app/controllers/hr_lite/admin/audit_logs_controller.rb +4 -1
- data/app/controllers/hr_lite/admin/employees_controller.rb +36 -12
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +19 -13
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +16 -0
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
- data/app/controllers/hr_lite/admin/resignations_controller.rb +17 -5
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +22 -8
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +5 -0
- data/app/controllers/hr_lite/org_controller.rb +6 -1
- data/app/controllers/hr_lite/resignations_controller.rb +6 -1
- data/app/helpers/hr_lite/application_helper.rb +2 -12
- data/app/jobs/hr_lite/application_job.rb +5 -0
- data/app/jobs/hr_lite/daily_digest_job.rb +1 -1
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +1 -1
- data/app/jobs/hr_lite/payroll_auto_draft_job.rb +5 -2
- data/app/models/concerns/hr_lite/audited.rb +11 -4
- data/app/models/hr_lite/appraisal.rb +9 -1
- data/app/models/hr_lite/audit_log.rb +36 -0
- data/app/models/hr_lite/comp_off_request.rb +8 -16
- data/app/models/hr_lite/designation_change.rb +18 -1
- data/app/models/hr_lite/employee_profile.rb +5 -1
- data/app/models/hr_lite/leave_balance.rb +51 -5
- data/app/models/hr_lite/leave_request.rb +36 -5
- data/app/models/hr_lite/leave_type.rb +13 -3
- data/app/models/hr_lite/payroll_run.rb +68 -13
- data/app/models/hr_lite/regularization_request.rb +11 -4
- data/app/models/hr_lite/resignation.rb +27 -0
- data/app/models/hr_lite/salary_slip.rb +8 -5
- data/app/services/hr_lite/attendance_puncher.rb +8 -1
- data/app/services/hr_lite/calculators/esi.rb +7 -4
- data/app/services/hr_lite/calculators/professional_tax.rb +5 -1
- data/app/services/hr_lite/calculators/tds.rb +8 -1
- data/app/services/hr_lite/leave_day_counter.rb +9 -4
- data/app/services/hr_lite/overview_query.rb +12 -1
- data/app/services/hr_lite/payroll_run_processor.rb +9 -1
- data/app/services/hr_lite/slip_builder.rb +54 -8
- data/app/views/hr_lite/admin/attendances/index.html.erb +4 -1
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +19 -5
- data/app/views/hr_lite/admin/employees/_form.html.erb +20 -2
- data/app/views/hr_lite/admin/employees/show.html.erb +25 -15
- data/app/views/hr_lite/admin/leave_requests/show.html.erb +37 -5
- data/app/views/hr_lite/admin/overview/index.html.erb +21 -0
- data/app/views/hr_lite/admin/payroll_runs/show.html.erb +3 -1
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +18 -4
- data/app/views/hr_lite/attendance/_punch_card.html.erb +22 -2
- data/app/views/hr_lite/career/show.html.erb +7 -1
- data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +5 -1
- data/app/views/hr_lite/salary_slips/pdf.html.erb +5 -5
- data/app/views/layouts/hr_lite/application.html.erb +42 -4
- data/config/routes.rb +1 -1
- data/db/migrate/20260807184103_add_live_uniqueness_indexes_to_hr_lite.rb +28 -0
- data/db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb +14 -0
- data/db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb +12 -0
- data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
- data/lib/hr_lite/amount_in_words.rb +15 -2
- data/lib/hr_lite/configuration.rb +3 -6
- data/lib/hr_lite/financial_year.rb +26 -0
- data/lib/hr_lite/money.rb +18 -0
- data/lib/hr_lite/notifications.rb +23 -8
- data/lib/hr_lite/statutory_rate_card.rb +51 -6
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +23 -2
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
88
|
+
cap = [ as_of, finish ].min
|
|
55
89
|
start > cap ? 0 : months_between(start, cap)
|
|
56
90
|
else
|
|
57
|
-
months_between(start,
|
|
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 =
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|