hr_lite 0.19.0 → 0.20.1
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 +73 -0
- data/README.md +9 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +2 -1
- data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/documents_controller.rb +2 -0
- data/app/controllers/hr_lite/admin/expenses_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +1 -0
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -2
- data/app/controllers/hr_lite/admin/overview_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +5 -1
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/reports_controller.rb +3 -1
- data/app/controllers/hr_lite/admin/salary_structures_controller.rb +16 -2
- data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +1 -0
- data/app/controllers/hr_lite/home_controller.rb +3 -0
- data/app/controllers/hr_lite/salary_slips_controller.rb +5 -0
- data/app/helpers/hr_lite/application_helper.rb +4 -1
- data/app/models/concerns/hr_lite/audited.rb +2 -1
- data/app/models/hr_lite/audit_log.rb +1 -1
- data/app/models/hr_lite/document.rb +3 -0
- data/app/models/hr_lite/expense.rb +8 -0
- data/app/models/hr_lite/salary_structure.rb +57 -0
- data/app/services/hr_lite/overview_query.rb +13 -7
- data/app/services/hr_lite/slip_builder.rb +3 -21
- data/app/views/hr_lite/admin/salary_structures/_form.html.erb +4 -0
- data/app/views/hr_lite/home/index.html.erb +21 -0
- data/app/views/hr_lite/salary_slips/index.html.erb +47 -0
- data/app/views/hr_lite/team/show.html.erb +1 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d52aa888b9029219bfde72b9d7eabcae22691d9bb5ec73ec6a924191cdf8ac2
|
|
4
|
+
data.tar.gz: b1e9c215421a30a9c521059c90349db86276f0e74f3ac4c7eb4a34a05ca2cd2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 349fcb87fb86b6ee48633227123a83e712bdd5486cda707de5f3c70d8f2b4df941747610fbf5a817336ece750f4c3d28727559faff4997c840a1fd598be83499
|
|
7
|
+
data.tar.gz: a1c509e0a1189eeac70b67ab848ab04b3507ce85ce456d17396dca9419911187fd26021bbb997d779f07d6d51835e58c105dd4b52f0a7d3ee788899a3781ca9d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.1] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
Security fixes. No migration.
|
|
13
|
+
|
|
14
|
+
### Upgrading
|
|
15
|
+
|
|
16
|
+
Document numbers are now encrypted with Active Record Encryption. Rows
|
|
17
|
+
saved before this release are plain text, so the host needs
|
|
18
|
+
`config.active_record.encryption.support_unencrypted_data = true` to keep
|
|
19
|
+
reading them, and should re-save each `HrLite::Document` once to encrypt
|
|
20
|
+
the old numbers.
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- **Nobody decides their own request.** A manager could approve their own
|
|
25
|
+
leave, comp-off and attendance fixes, Finance its own expense claims, and
|
|
26
|
+
anyone recording leave for someone else could record their own as
|
|
27
|
+
approved. HR could verify or reject its own documents. All refused now.
|
|
28
|
+
- **Nobody edits their own records from the admin screens.** HR rewriting
|
|
29
|
+
its own attendance, adjusting its own leave balance, or Finance verifying
|
|
30
|
+
its own tax declaration is refused.
|
|
31
|
+
- **Document numbers are encrypted.** Aadhaar, PAN and passport numbers
|
|
32
|
+
were stored in plain text and copied into audit diffs and the leadership
|
|
33
|
+
audit email. Loans join the money tier for audit mail.
|
|
34
|
+
- **A rejected tax declaration no longer lowers TDS.**
|
|
35
|
+
- **Payroll CSVs defuse formulas.** Register and report cells that open
|
|
36
|
+
with `=`, `+`, `-` or `@` are escaped, since names are staff-typed.
|
|
37
|
+
- **The payout register needs `payroll.export`**, not just
|
|
38
|
+
`payroll.manage`.
|
|
39
|
+
- **The admin overview respects reach.** A team-scoped manager sees only
|
|
40
|
+
the people they reach, not the whole company.
|
|
41
|
+
- **Expense receipts** take the same type and size rule as documents.
|
|
42
|
+
|
|
43
|
+
## [0.20.0] - 2026-10-01
|
|
44
|
+
|
|
45
|
+
Salary as CTC, and who is out today. No migration.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **Fill from CTC.** The salary structure form takes an annual CTC, and
|
|
50
|
+
"Fill from CTC" splits it: Basic is half the monthly CTC, HRA is 40% of
|
|
51
|
+
Basic, the employer's PF and ESI are paid out of the CTC, and Special
|
|
52
|
+
allowance takes the rest, rounding included. Nothing is saved until Save,
|
|
53
|
+
which stores the lines shown; the CTC itself is never stored. A CTC too low
|
|
54
|
+
for the split is refused. The form shows the monthly gross and CTC a
|
|
55
|
+
structure works out to, and prefills the CTC on edit. Both use the rate
|
|
56
|
+
card of the month the structure takes effect, and the typed CTC never
|
|
57
|
+
reaches the audit log.
|
|
58
|
+
- **`SalaryStructure#breakup`** gives one month of a structure: earnings, the
|
|
59
|
+
employee's PF, ESI and professional tax, the employer's PF and ESI, gross,
|
|
60
|
+
CTC and in-hand, from the existing statutory calculators. ESI is decided on
|
|
61
|
+
the salary that opened the ESIC period, as payroll does, and the yearly
|
|
62
|
+
professional tax is summed month by month so a February top-up counts once.
|
|
63
|
+
- **My salary.** The salary slips page opens with the employee's current
|
|
64
|
+
structure as a Monthly | Yearly table, a structure HR has saved for a later
|
|
65
|
+
month ("New salary from …"), and earlier ones with their yearly CTC. Income
|
|
66
|
+
tax stays on the slips. Slip release day does not hide the card.
|
|
67
|
+
- **Out today** on Home: everyone on approved leave today, by name, with the
|
|
68
|
+
leave's dates (`01 Oct – 05 Oct`, or `01 Oct (half day)`), and a link to the
|
|
69
|
+
Team board. The leave type is not shown, and staff who have left are not
|
|
70
|
+
listed.
|
|
71
|
+
- **Team board** shows each leave's dates next to "On leave", and hovering a
|
|
72
|
+
name on the board or on Out today reads "On leave: 30 Sep – 03 Oct".
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- Form action buttons wrap on a narrow phone, so a third button no longer
|
|
77
|
+
pushes the salary structure form sideways.
|
|
78
|
+
|
|
79
|
+
### Upgrading
|
|
80
|
+
|
|
81
|
+
No migration, no config.
|
|
82
|
+
|
|
10
83
|
## [0.19.0] - 2026-09-30
|
|
11
84
|
|
|
12
85
|
Each night closes the day. A missed check-out becomes a half day, and
|
data/README.md
CHANGED
|
@@ -277,6 +277,9 @@ the audit row is the trail. Older days, off days, the third fix of a week, a cha
|
|
|
277
277
|
to a real punch, a day HR already regularized or rejected, or a fix that cannot
|
|
278
278
|
apply (no check-in that day) stay ordinary tickets for HR.
|
|
279
279
|
|
|
280
|
+
Home has an "Out today" card: everyone on approved leave today, with the leave's
|
|
281
|
+
dates, so the team knows who is away and until when. The leave type is not shown.
|
|
282
|
+
|
|
280
283
|
### Holidays and calendar
|
|
281
284
|
|
|
282
285
|
`bin/rails hr_lite:seed` inserts the three fixed-date national holidays
|
|
@@ -306,6 +309,12 @@ WickedPdf path if `wicked_pdf` is in the bundle. The exact math, rounding rules
|
|
|
306
309
|
and what is deliberately not modelled are in `docs/PAYROLL.md` — read it with
|
|
307
310
|
your accountant before the first run.
|
|
308
311
|
|
|
312
|
+
HR can enter a structure as an annual CTC: "Fill from CTC" sets Basic to half the
|
|
313
|
+
monthly CTC and HRA to 40% of Basic, pays the employer's PF and ESI out of the CTC,
|
|
314
|
+
and puts the rest in Special allowance. Employees see their own breakup, monthly
|
|
315
|
+
and yearly, at the top of `/salary_slips`, along with any revision HR has saved
|
|
316
|
+
for a later month.
|
|
317
|
+
|
|
309
318
|
### Kudos and @mentions
|
|
310
319
|
|
|
311
320
|
The kudos wall (`/kudos`) lets staff thank each other with @mentions. The picker
|
|
@@ -195,6 +195,7 @@
|
|
|
195
195
|
.hrl-table th, .hrl-table td { text-align: left; padding: .6rem .7rem; border-bottom: 1px solid var(--hrl-line); vertical-align: top; }
|
|
196
196
|
.hrl-table th { color: var(--hrl-muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .03em; }
|
|
197
197
|
.hrl-table td.hrl-num, .hrl-table th.hrl-num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
198
|
+
.hrl-table--tight th, .hrl-table--tight td { padding: .5rem .35rem; }
|
|
198
199
|
@media (max-width: 640px) {
|
|
199
200
|
.hrl-table--stack thead { display: none; }
|
|
200
201
|
.hrl-table--stack tr { display: block; border-bottom: 6px solid var(--hrl-bg); }
|
|
@@ -227,7 +228,7 @@
|
|
|
227
228
|
.hrl-field--check label { margin-bottom: 0; font-weight: 600; }
|
|
228
229
|
.hrl-errors { background: var(--hrl-bad-bg); color: var(--hrl-bad); border-radius: var(--hrl-radius-sm); padding: .7rem .9rem; margin-bottom: .85rem; font-size: .86rem; }
|
|
229
230
|
.hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
|
|
230
|
-
.hrl-form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
|
|
231
|
+
.hrl-form-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1rem; }
|
|
231
232
|
.hrl-form-actions .hrl-btn { flex: 1; }
|
|
232
233
|
@media (min-width: 700px) { .hrl-form-actions .hrl-btn { flex: 0 0 auto; min-width: 9rem; } }
|
|
233
234
|
|
|
@@ -82,7 +82,8 @@ module HrLite
|
|
|
82
82
|
# authorities, so they resolve through different permissions — a role
|
|
83
83
|
# can be given the board without being given the pencil.
|
|
84
84
|
def find_visible_employee = employee_within("attendance.view")
|
|
85
|
-
|
|
85
|
+
# Nobody rewrites their own punches: team and company scope include the holder.
|
|
86
|
+
def find_manageable_employee = employee_within("attendance.manage").tap { |e| raise ActiveRecord::RecordNotFound if e.id == hr_current_user.id }
|
|
86
87
|
|
|
87
88
|
def employee_within(permission)
|
|
88
89
|
employee = HrLite.user_klass.find(params[:user_id])
|
|
@@ -40,8 +40,9 @@ module HrLite
|
|
|
40
40
|
# A manager decides for their own reports; HR decides for everyone.
|
|
41
41
|
# Scoped through the relation, so reaching for somebody else's report
|
|
42
42
|
# is a 404 rather than a 403 that confirms the person exists.
|
|
43
|
+
# Nobody decides their own request: team scope includes the approver.
|
|
43
44
|
def decidable
|
|
44
|
-
hr_scope(CompOffRequest.all, "leave.approve")
|
|
45
|
+
hr_scope(CompOffRequest.all, "leave.approve").where.not(user_id: hr_current_user.id)
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def find_decidable = decidable.find(params[:id])
|
|
@@ -54,6 +54,8 @@ module HrLite
|
|
|
54
54
|
# whoever happens to run HR.
|
|
55
55
|
def find_manageable
|
|
56
56
|
document = Document.find(params[:id])
|
|
57
|
+
# readable_by? always admits the owner, and nobody verifies their own paperwork.
|
|
58
|
+
raise ActiveRecord::RecordNotFound if document.user_id == hr_current_user.id
|
|
57
59
|
raise ActiveRecord::RecordNotFound unless document.readable_by?(hr_current_user)
|
|
58
60
|
|
|
59
61
|
document
|
|
@@ -39,7 +39,8 @@ module HrLite
|
|
|
39
39
|
|
|
40
40
|
def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# Nobody approves their own claim: team scope includes the approver.
|
|
43
|
+
def decidable = hr_scope(Expense.all, "expense.approve").where.not(user_id: hr_current_user.id)
|
|
43
44
|
end
|
|
44
45
|
end
|
|
45
46
|
end
|
|
@@ -13,6 +13,7 @@ module HrLite
|
|
|
13
13
|
# A manager reaches this screen through leave.approve; changing
|
|
14
14
|
# somebody's quota is leave.manage, for THAT person.
|
|
15
15
|
hr_require_reach!("leave.manage", user)
|
|
16
|
+
raise ActiveRecord::RecordNotFound if user.id == hr_current_user.id # never one's own quota
|
|
16
17
|
type = LeaveType.find(params[:leave_type_id])
|
|
17
18
|
# Unsanitised, a missing param wrote the adjustment into leave year 0,
|
|
18
19
|
# where no screen can ever show it.
|
|
@@ -84,13 +84,14 @@ module HrLite
|
|
|
84
84
|
# somebody else's report gets a 404 through the scoped relation rather
|
|
85
85
|
# than a 403 — the same shape every employee-tier screen already uses,
|
|
86
86
|
# and it does not confirm that the other person exists.
|
|
87
|
+
# Nobody decides their own request: team scope includes the approver.
|
|
87
88
|
def decidable
|
|
88
|
-
hr_scope(LeaveRequest.all, "leave.approve")
|
|
89
|
+
hr_scope(LeaveRequest.all, "leave.approve").where.not(user_id: hr_current_user.id)
|
|
89
90
|
end
|
|
90
91
|
|
|
91
92
|
def find_decidable = decidable.find(params[:id])
|
|
92
93
|
|
|
93
|
-
def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve")
|
|
94
|
+
def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve").where.not(user_id: hr_current_user.id)
|
|
94
95
|
end
|
|
95
96
|
end
|
|
96
97
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
3
|
class PayrollRunsController < SuperadminController
|
|
4
|
+
# The register carries every bank account: its own key, not just payroll.manage.
|
|
5
|
+
before_action -> { hr_require_permission!("payroll.export", scope: :all) }, only: :register
|
|
4
6
|
def index
|
|
5
7
|
@runs = paginate(PayrollRun.recent_first)
|
|
6
8
|
end
|
|
@@ -82,6 +84,8 @@ module HrLite
|
|
|
82
84
|
|
|
83
85
|
def register_csv(run)
|
|
84
86
|
require "csv"
|
|
87
|
+
# A cell opening with = + - @ runs as a formula; names are staff-typed.
|
|
88
|
+
safe = ->(v) { v.is_a?(String) && v.match?(/\A[=+\-@\t\r]/) ? "'#{v}" : v }
|
|
85
89
|
CSV.generate do |csv|
|
|
86
90
|
csv << [ "Code", "Name", "Days", "LOP", "Gross", "PF", "ESI", "PT", "TDS",
|
|
87
91
|
"Net pay", "Bank", "Account", "IFSC" ]
|
|
@@ -95,7 +99,7 @@ module HrLite
|
|
|
95
99
|
deductions.dig("esi_employee", "amount") || 0, deductions.dig("pt", "amount") || 0,
|
|
96
100
|
deductions.dig("tds", "amount") || 0, slip.net_pay,
|
|
97
101
|
profile&.bank_name, profile&.bank_account_number, profile&.bank_ifsc
|
|
98
|
-
]
|
|
102
|
+
].map(&safe)
|
|
99
103
|
end
|
|
100
104
|
end
|
|
101
105
|
end
|
|
@@ -40,8 +40,9 @@ module HrLite
|
|
|
40
40
|
# A manager decides for their own reports; HR decides for everyone.
|
|
41
41
|
# Scoped through the relation, so reaching for somebody else's report
|
|
42
42
|
# is a 404 rather than a 403 that confirms the person exists.
|
|
43
|
+
# Nobody decides their own request: team scope includes the approver.
|
|
43
44
|
def decidable
|
|
44
|
-
hr_scope(RegularizationRequest.all, "attendance.manage")
|
|
45
|
+
hr_scope(RegularizationRequest.all, "attendance.manage").where.not(user_id: hr_current_user.id)
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def find_decidable = decidable.find(params[:id])
|
|
@@ -141,9 +141,11 @@ module HrLite
|
|
|
141
141
|
require "csv"
|
|
142
142
|
return "" if rows.empty?
|
|
143
143
|
|
|
144
|
+
# A cell opening with = + - @ runs as a formula; names are staff-typed.
|
|
145
|
+
safe = ->(v) { v.is_a?(String) && v.match?(/\A[=+\-@\t\r]/) ? "'#{v}" : v }
|
|
144
146
|
CSV.generate do |csv|
|
|
145
147
|
csv << rows.first.keys
|
|
146
|
-
rows.each { |row| csv << row.values }
|
|
148
|
+
rows.each { |row| csv << row.values.map(&safe) }
|
|
147
149
|
end
|
|
148
150
|
end
|
|
149
151
|
end
|
|
@@ -12,6 +12,8 @@ module HrLite
|
|
|
12
12
|
@structure = SalaryStructure.new(structure_params.merge(
|
|
13
13
|
user_id: @profile.user_id, created_by_id: hr_current_user.id
|
|
14
14
|
))
|
|
15
|
+
return fill_from_ctc(:new) if params[:fill_from_ctc]
|
|
16
|
+
|
|
15
17
|
if @structure.save
|
|
16
18
|
redirect_to admin_employee_path(@profile), notice: "Salary structure saved."
|
|
17
19
|
else
|
|
@@ -25,7 +27,10 @@ module HrLite
|
|
|
25
27
|
|
|
26
28
|
def update
|
|
27
29
|
@structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
|
|
28
|
-
|
|
30
|
+
@structure.assign_attributes(structure_params)
|
|
31
|
+
return fill_from_ctc(:edit) if params[:fill_from_ctc]
|
|
32
|
+
|
|
33
|
+
if @structure.save
|
|
29
34
|
redirect_to admin_employee_path(@profile), notice: "Salary structure updated."
|
|
30
35
|
else
|
|
31
36
|
render :edit, status: :unprocessable_entity
|
|
@@ -34,13 +39,22 @@ module HrLite
|
|
|
34
39
|
|
|
35
40
|
private
|
|
36
41
|
|
|
42
|
+
# Shows the split for a check; only the Save button writes it.
|
|
43
|
+
def fill_from_ctc(view)
|
|
44
|
+
@structure.fill_from_ctc
|
|
45
|
+
return render(view, status: :unprocessable_entity) unless @structure.valid?
|
|
46
|
+
|
|
47
|
+
flash.now[:notice] = "Filled from CTC — check the lines, then Save."
|
|
48
|
+
render view
|
|
49
|
+
end
|
|
50
|
+
|
|
37
51
|
def set_profile
|
|
38
52
|
@profile = EmployeeProfile.find(params[:employee_id])
|
|
39
53
|
end
|
|
40
54
|
|
|
41
55
|
def structure_params
|
|
42
56
|
params.require(:salary_structure).permit(
|
|
43
|
-
:effective_from, :basic, :hra, :special_allowance, :other_earnings,
|
|
57
|
+
:annual_ctc, :effective_from, :basic, :hra, :special_allowance, :other_earnings,
|
|
44
58
|
:pf_applicable, :pf_on_full_basic, :esi_applicable, :pt_state, :notes
|
|
45
59
|
)
|
|
46
60
|
end
|
|
@@ -62,6 +62,7 @@ module HrLite
|
|
|
62
62
|
def find_manageable
|
|
63
63
|
declaration = TaxDeclaration.find(params[:id])
|
|
64
64
|
hr_require_reach!("tax.manage", declaration.user)
|
|
65
|
+
raise ActiveRecord::RecordNotFound if declaration.user_id == hr_current_user.id # never one's own
|
|
65
66
|
declaration
|
|
66
67
|
end
|
|
67
68
|
|
|
@@ -2,6 +2,9 @@ module HrLite
|
|
|
2
2
|
class HomeController < ApplicationController
|
|
3
3
|
def index
|
|
4
4
|
@latest_kudos = Kudo.recent.includes(:giver, kudo_mentions: :user).limit(3)
|
|
5
|
+
@out_today = LeaveRequest.active_on(Date.current).includes(:user)
|
|
6
|
+
.where(user_id: HrLite.active_employees.map(&:id))
|
|
7
|
+
.sort_by { |leave| hr_display_name(leave.user).downcase }
|
|
5
8
|
end
|
|
6
9
|
end
|
|
7
10
|
end
|
|
@@ -4,6 +4,11 @@ module HrLite
|
|
|
4
4
|
class SalarySlipsController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@slips = paginate(own_released.recent_first)
|
|
7
|
+
# Structures show as soon as HR saves them; slip_release_day gates slips only.
|
|
8
|
+
@upcoming, started = SalaryStructure.where(user_id: hr_current_user.id).order(:effective_from)
|
|
9
|
+
.partition { |s| s.effective_from > Date.current }
|
|
10
|
+
@structure = started.pop
|
|
11
|
+
@past = started.reverse
|
|
7
12
|
end
|
|
8
13
|
|
|
9
14
|
def show
|
|
@@ -131,6 +131,9 @@ module HrLite
|
|
|
131
131
|
HrLite::Money.format(amount)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Whole rupees, for salary figures that are planned rather than paid.
|
|
135
|
+
def hrl_rupees(amount) = hrl_money(amount.round).delete_suffix(".00")
|
|
136
|
+
|
|
134
137
|
# Indian-system amount in words for the slip footer. Delegates to the
|
|
135
138
|
# PORO so host-rendered templates (config.render_pdf) work too.
|
|
136
139
|
def hrl_amount_in_words(amount)
|
|
@@ -149,7 +152,7 @@ module HrLite
|
|
|
149
152
|
case row.kind
|
|
150
153
|
when :holiday then hrl_status_badge("Holiday", "hrl-badge--muted", worked_hint(row))
|
|
151
154
|
when :weekend then hrl_status_badge("Weekend", "hrl-badge--muted", worked_hint(row))
|
|
152
|
-
when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn")
|
|
155
|
+
when :leave then hrl_status_badge("On leave (#{row.leave.leave_type.code})", "hrl-badge--warn", row.leave.date_range_label)
|
|
153
156
|
when :half_day_leave then hrl_status_badge("Half-day leave (#{row.leave.leave_type.code})", "hrl-badge--warn", worked_hint(row))
|
|
154
157
|
when :upcoming then hrl_status_badge("\u2014", "hrl-badge--muted")
|
|
155
158
|
when :absent
|
|
@@ -46,7 +46,8 @@ module HrLite
|
|
|
46
46
|
when "destroy"
|
|
47
47
|
{ "_destroyed" => hr_lite_audit_label }
|
|
48
48
|
else
|
|
49
|
-
|
|
49
|
+
# Columns only: a virtual attribute (a typed annual CTC) is form input, not a stored change.
|
|
50
|
+
saved_changes.slice(*self.class.column_names).except(*SKIPPED_ATTRIBUTES).to_h do |attr, (from, to)|
|
|
50
51
|
if encrypted.include?(attr)
|
|
51
52
|
[ attr, REDACTED ]
|
|
52
53
|
else
|
|
@@ -14,7 +14,7 @@ module HrLite
|
|
|
14
14
|
# leadership audit screen or the policy.changed email.
|
|
15
15
|
MONEY_TIER_TYPES = %w[
|
|
16
16
|
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
|
-
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
|
|
17
|
+
HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
scope :recent, -> { order(created_at: :desc) }
|
|
@@ -5,6 +5,9 @@ module HrLite
|
|
|
5
5
|
class Document < ApplicationRecord
|
|
6
6
|
include Audited
|
|
7
7
|
|
|
8
|
+
# An Aadhaar, PAN or passport number: encrypted, which also redacts it from audit diffs and their emails.
|
|
9
|
+
encrypts :reference_number
|
|
10
|
+
|
|
8
11
|
VISIBILITIES = %w[self hr money].freeze
|
|
9
12
|
VERIFICATIONS = %w[pending verified rejected].freeze
|
|
10
13
|
|
|
@@ -23,6 +23,7 @@ module HrLite
|
|
|
23
23
|
validate :spent_on_is_not_in_the_future
|
|
24
24
|
validate :within_the_category_cap, on: :create
|
|
25
25
|
validate :receipt_is_present_when_the_category_demands_one
|
|
26
|
+
validate :receipt_is_a_small_pdf_or_image
|
|
26
27
|
|
|
27
28
|
scope :awaiting_reimbursement, -> { where(status: "approved") }
|
|
28
29
|
scope :recent_first, -> { order(spent_on: :desc, id: :desc) }
|
|
@@ -138,5 +139,12 @@ module HrLite
|
|
|
138
139
|
|
|
139
140
|
errors.add(:receipt, "is required for #{category.name}")
|
|
140
141
|
end
|
|
142
|
+
|
|
143
|
+
def receipt_is_a_small_pdf_or_image
|
|
144
|
+
return unless receipt.attached?
|
|
145
|
+
return errors.add(:receipt, "must be a PDF or an image") unless Document::ALLOWED_TYPES.include?(receipt.blob.content_type)
|
|
146
|
+
|
|
147
|
+
errors.add(:receipt, "must be under #{Document::MAX_BYTES / 1.megabyte} MB") if receipt.blob.byte_size > Document::MAX_BYTES
|
|
148
|
+
end
|
|
141
149
|
end
|
|
142
150
|
end
|
|
@@ -11,12 +11,15 @@ module HrLite
|
|
|
11
11
|
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
12
12
|
|
|
13
13
|
encrypted_money :basic, :hra, :special_allowance, :other_earnings
|
|
14
|
+
# Typed on the form to fill the lines; CTC is always derived, never stored.
|
|
15
|
+
attribute :annual_ctc, :decimal
|
|
14
16
|
|
|
15
17
|
validates :effective_from, presence: true, uniqueness: { scope: :user_id }
|
|
16
18
|
validates :basic, presence: true
|
|
17
19
|
validate :basic_positive
|
|
18
20
|
validate :effective_from_is_first_of_month
|
|
19
21
|
validates :pt_state, presence: true
|
|
22
|
+
validate { errors.add(:base, "CTC is too low for this split") if special_allowance&.negative? }
|
|
20
23
|
|
|
21
24
|
def self.effective_for(user, period_month)
|
|
22
25
|
where(user_id: user.id)
|
|
@@ -33,6 +36,60 @@ module HrLite
|
|
|
33
36
|
monthly_gross * 12
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
# A month of this structure: earnings, the employee's statutory
|
|
40
|
+
# deductions and the employer's contributions, via the payroll calculators.
|
|
41
|
+
def breakup(on: Date.current)
|
|
42
|
+
month = on.beginning_of_month
|
|
43
|
+
rates = StatutoryRateCard.for(month)
|
|
44
|
+
gross = monthly_gross
|
|
45
|
+
pf = Calculators::Pf.call(basic_earned: basic, on_full_basic: pf_on_full_basic, rates: rates[:pf]) if pf_applicable
|
|
46
|
+
esi = Calculators::Esi.call(monthly_gross: esi_reference_gross(month), gross_earned: gross, applicable: esi_applicable, rates: rates[:esi])
|
|
47
|
+
# Not flat: some states top up February, so the year is summed month by month.
|
|
48
|
+
pts = (0..11).map { |i| Calculators::ProfessionalTax.call(state: pt_state, gross_earned: gross, period_month: month >> i, rates: rates[:pt]) }
|
|
49
|
+
earnings, deductions, employer = [
|
|
50
|
+
{ "Basic" => basic, "HRA" => hra, "Special allowance" => special_allowance, "Other" => other_earnings },
|
|
51
|
+
{ "PF" => pf&.employee, "ESI" => esi.employee, "Professional tax" => pts.first },
|
|
52
|
+
{ "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer }
|
|
53
|
+
].map { |rows| rows.select { |_, amount| Money.d(amount).positive? } }
|
|
54
|
+
monthly_ctc = gross + employer.values.sum(BigDecimal(0))
|
|
55
|
+
in_hand = gross - deductions.values.sum(BigDecimal(0))
|
|
56
|
+
{ earnings: earnings, deductions: deductions, employer: employer,
|
|
57
|
+
monthly_gross: gross, monthly_ctc: monthly_ctc, annual_ctc: monthly_ctc * 12, in_hand: in_hand,
|
|
58
|
+
yearly: { "Professional tax" => pts.sum, "In-hand before tax" => (in_hand + pts.first) * 12 - pts.sum } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ESIC contribution periods run April–September and October–March.
|
|
62
|
+
# Eligibility is fixed for the whole period, so it is decided on the
|
|
63
|
+
# salary in force on its first day — re-deciding it every month dropped
|
|
64
|
+
# someone out of ESI the moment a mid-period raise crossed the ceiling.
|
|
65
|
+
def esi_reference_gross(month)
|
|
66
|
+
start = if month.month.between?(4, 9)
|
|
67
|
+
Date.new(month.year, 4, 1)
|
|
68
|
+
elsif month.month >= 10
|
|
69
|
+
Date.new(month.year, 10, 1)
|
|
70
|
+
else
|
|
71
|
+
Date.new(month.year - 1, 10, 1)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Started mid-period: the structure in force when it opened decides (a mid-period joiner has none).
|
|
75
|
+
earlier = self.class.effective_for(user, start) if effective_from&.after?(start)
|
|
76
|
+
(earlier || self).monthly_gross
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Owner's split of annual_ctc: Basic 50% of the monthly CTC, HRA 40% of
|
|
80
|
+
# Basic, employer PF and ESI paid out of the CTC; Special takes the rest.
|
|
81
|
+
def fill_from_ctc(on: effective_from || Date.current)
|
|
82
|
+
monthly_ctc = Money.round_rupee(Money.d(annual_ctc) / 12)
|
|
83
|
+
self.basic = Money.round_rupee(monthly_ctc / 2)
|
|
84
|
+
self.hra = Money.round_rupee(basic * BigDecimal("0.4"))
|
|
85
|
+
self.special_allowance = 0
|
|
86
|
+
rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF"))
|
|
87
|
+
# Employer ESI is a share of the gross beside it: gross + ESI = rest.
|
|
88
|
+
gross = (rest / (1 + StatutoryRateCard.for(on.beginning_of_month)[:esi][:employer_rate])).floor
|
|
89
|
+
self.special_allowance = gross - basic - hra - Money.d(other_earnings)
|
|
90
|
+
self.special_allowance += rest - gross unless breakup(on: on)[:employer].key?("ESI")
|
|
91
|
+
end
|
|
92
|
+
|
|
36
93
|
private
|
|
37
94
|
|
|
38
95
|
def basic_positive
|
|
@@ -3,36 +3,38 @@ module HrLite
|
|
|
3
3
|
# one source, so they always agree. Sections return Relations (callers
|
|
4
4
|
# paginate or cap as they see fit).
|
|
5
5
|
class OverviewQuery
|
|
6
|
-
|
|
6
|
+
# user_ids nil = everyone; a team-scoped viewer sees only the people they reach.
|
|
7
|
+
def initialize(date: Date.current, user_ids: nil)
|
|
7
8
|
@date = date
|
|
9
|
+
@user_ids = user_ids
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def pending_requests
|
|
11
|
-
LeaveRequest.pending.includes(:leave_type, :user).order(:created_at)
|
|
13
|
+
reach(LeaveRequest.pending.includes(:leave_type, :user).order(:created_at))
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def on_leave_today
|
|
15
|
-
LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date)
|
|
17
|
+
reach(LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date))
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# Comp-off and regularization are approvals too. Counting leave alone made
|
|
19
21
|
# the board announce "All present and accounted for" while both queues
|
|
20
22
|
# still had work in them.
|
|
21
23
|
def pending_comp_offs
|
|
22
|
-
CompOffRequest.pending.includes(:user).recent_first
|
|
24
|
+
reach(CompOffRequest.pending.includes(:user).recent_first)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def pending_regularizations
|
|
26
|
-
RegularizationRequest.pending.includes(:user).recent_first
|
|
28
|
+
reach(RegularizationRequest.pending.includes(:user).recent_first)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def flagged_today
|
|
30
|
-
AttendanceRecord.for_date(@date).flagged.includes(:user)
|
|
32
|
+
reach(AttendanceRecord.for_date(@date).flagged.includes(:user))
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# AttendanceCloseJob fills check_out_at, so the days it closed count too.
|
|
34
36
|
def missing_checkout_yesterday
|
|
35
|
-
yesterday = AttendanceRecord.for_date(@date - 1)
|
|
37
|
+
yesterday = reach(AttendanceRecord.for_date(@date - 1))
|
|
36
38
|
yesterday.missing_checkout.or(yesterday.where(regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE))
|
|
37
39
|
.includes(:user)
|
|
38
40
|
end
|
|
@@ -49,5 +51,9 @@ module HrLite
|
|
|
49
51
|
def empty?
|
|
50
52
|
kpis.values.all?(&:zero?)
|
|
51
53
|
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def reach(relation) = @user_ids ? relation.where(user_id: @user_ids) : relation
|
|
52
58
|
end
|
|
53
59
|
end
|
|
@@ -59,7 +59,7 @@ module HrLite
|
|
|
59
59
|
)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: esi_gross,
|
|
62
|
+
esi = Calculators::Esi.call(monthly_gross: @structure.esi_reference_gross(@run.period_month), gross_earned: esi_gross,
|
|
63
63
|
applicable: @structure.esi_applicable, rates: @rates[:esi])
|
|
64
64
|
if esi.applicable?
|
|
65
65
|
deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
|
|
@@ -135,7 +135,8 @@ module HrLite
|
|
|
135
135
|
def annual_deductions
|
|
136
136
|
declaration = TaxDeclaration.for(@user, @run.period_month)
|
|
137
137
|
return @profile.declared_annual_deductions if declaration.nil?
|
|
138
|
-
|
|
138
|
+
# A rejected declaration lowers nothing: its self-declared total stays editable and unchecked.
|
|
139
|
+
return @profile.declared_annual_deductions if declaration.draft? || declaration.rejected?
|
|
139
140
|
|
|
140
141
|
declaration.allowable_total
|
|
141
142
|
end
|
|
@@ -197,25 +198,6 @@ module HrLite
|
|
|
197
198
|
(to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
|
|
198
199
|
end
|
|
199
200
|
|
|
200
|
-
# ESIC contribution periods run April–September and October–March.
|
|
201
|
-
# Eligibility is fixed for the whole period, so it is decided on the
|
|
202
|
-
# salary in force on its first day — re-deciding it every month dropped
|
|
203
|
-
# someone out of ESI the moment a mid-period raise crossed the ceiling.
|
|
204
|
-
def esi_reference_gross
|
|
205
|
-
month = @run.period_month
|
|
206
|
-
start = if month.month.between?(4, 9)
|
|
207
|
-
Date.new(month.year, 4, 1)
|
|
208
|
-
elsif month.month >= 10
|
|
209
|
-
Date.new(month.year, 10, 1)
|
|
210
|
-
else
|
|
211
|
-
Date.new(month.year - 1, 10, 1)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# No structure that far back (a mid-period joiner) — their own is the
|
|
215
|
-
# only salary this period has ever had.
|
|
216
|
-
(SalaryStructure.effective_for(@user, start) || @structure).monthly_gross
|
|
217
|
-
end
|
|
218
|
-
|
|
219
201
|
def serialize_rows(rows)
|
|
220
202
|
rows.map { |row|
|
|
221
203
|
row.transform_values { |value| value.is_a?(BigDecimal) ? value.to_s("F") : value }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<section class="hrl-card">
|
|
2
2
|
<%= form_with model: structure, url: url, method: method, scope: :salary_structure, local: true do |f| %>
|
|
3
3
|
<%= render "hr_lite/shared/form_errors", record: structure %>
|
|
4
|
+
<% breakup = structure.breakup(on: structure.effective_from || Date.current) if structure.basic %>
|
|
5
|
+
<div class="hrl-field"><%= f.label :annual_ctc, "Annual CTC (₹ a year)" %><%= f.number_field :annual_ctc, step: 1, value: (structure.annual_ctc || breakup&.dig(:annual_ctc))&.round %></div>
|
|
4
6
|
<div class="hrl-field">
|
|
5
7
|
<%= f.label :effective_from, "Effective from (1st of month)" %>
|
|
6
8
|
<%= f.date_field :effective_from %>
|
|
@@ -21,8 +23,10 @@
|
|
|
21
23
|
<%= f.select :pt_state, pt_states.map { |s| [ s.humanize, s ] } %>
|
|
22
24
|
</div>
|
|
23
25
|
<div class="hrl-field"><%= f.label :notes %><%= f.text_area :notes %></div>
|
|
26
|
+
<% if breakup %><p class="hrl-muted hrl-small">Monthly gross <%= hrl_money(breakup[:monthly_gross]) %> · CTC <%= hrl_money(breakup[:monthly_ctc]) %> a month</p><% end %>
|
|
24
27
|
<div class="hrl-form-actions">
|
|
25
28
|
<%= f.submit "Save structure", class: "hrl-btn hrl-btn--primary" %>
|
|
29
|
+
<%= f.submit "Fill from CTC", name: "fill_from_ctc", class: "hrl-btn" %>
|
|
26
30
|
<a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
|
|
27
31
|
</div>
|
|
28
32
|
<% end %>
|
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
<%= render "hr_lite/leave_requests/balance_chips" %>
|
|
8
8
|
<%= render "hr_lite/holidays/next_holiday" %>
|
|
9
9
|
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<h2 class="hrl-card__title">Out today</h2>
|
|
12
|
+
<% if @out_today.any? %>
|
|
13
|
+
<%# Name and dates only — the leave type (sick leave) is the employee's own business. %>
|
|
14
|
+
<div class="hrl-feed">
|
|
15
|
+
<% @out_today.first(10).each do |leave| %>
|
|
16
|
+
<div class="hrl-feed__item">
|
|
17
|
+
<div class="hrl-feed__body"><%= tag.strong hr_display_name(leave.user), title: "On leave: #{leave.date_range_label}" %>
|
|
18
|
+
<div class="hrl-feed__meta"><%= leave.date_range_label %></div></div>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
<% if @out_today.size > 10 %>
|
|
23
|
+
<p class="hrl-muted hrl-small">+<%= @out_today.size - 10 %> more</p>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% else %>
|
|
26
|
+
<p class="hrl-muted hrl-small">Everyone's in today.</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
<a class="hrl-small" href="<%= hr_lite.team_path %>">Team board</a>
|
|
29
|
+
</section>
|
|
30
|
+
|
|
10
31
|
<section class="hrl-card">
|
|
11
32
|
<h2 class="hrl-card__title">Latest kudos</h2>
|
|
12
33
|
<% if @latest_kudos.any? %>
|
|
@@ -3,6 +3,53 @@
|
|
|
3
3
|
<h1 class="hrl-page__title">My salary slips</h1>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<h2 class="hrl-card__title">My salary</h2>
|
|
8
|
+
<% if (breakup = @structure&.breakup) %>
|
|
9
|
+
<p class="hrl-small hrl-muted">Current salary from <%= @structure.effective_from.strftime("%d %b %Y") %></p>
|
|
10
|
+
<div class="hrl-table-wrap">
|
|
11
|
+
<%# Three narrow columns fit a 320px phone; stacking repeated the headings on every line. %>
|
|
12
|
+
<table class="hrl-table hrl-table--tight hrl-small">
|
|
13
|
+
<thead><tr><th>Component</th><th class="hrl-num">Monthly</th><th class="hrl-num">Yearly</th></tr></thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
<% { "Earnings" => breakup[:earnings].merge("Gross" => breakup[:monthly_gross]),
|
|
16
|
+
"Deductions" => breakup[:deductions].merge("In-hand before tax" => breakup[:in_hand]),
|
|
17
|
+
"Employer contributions" => breakup[:employer].merge("CTC" => breakup[:monthly_ctc]) }.each do |section, lines| %>
|
|
18
|
+
<tr><th colspan="3"><%= section %></th></tr>
|
|
19
|
+
<% lines.each do |label, amount| %>
|
|
20
|
+
<% weight = label == lines.keys.last ? :strong : :span %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td data-label="Component"><%= content_tag(weight, label) %></td>
|
|
23
|
+
<td data-label="Monthly" class="hrl-num"><%= content_tag(weight, hrl_rupees(amount)) %></td>
|
|
24
|
+
<td data-label="Yearly" class="hrl-num"><%= content_tag(weight, hrl_rupees(breakup[:yearly].fetch(label, amount * 12))) %></td>
|
|
25
|
+
</tr>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</tbody>
|
|
29
|
+
</table>
|
|
30
|
+
</div>
|
|
31
|
+
<p class="hrl-small hrl-muted">Income tax (TDS) depends on your declarations and is shown on each slip.</p>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% @upcoming.each do |structure| %>
|
|
34
|
+
<% upcoming = structure.breakup(on: structure.effective_from) %>
|
|
35
|
+
<h3 class="hrl-card__title">New salary from <%= structure.effective_from.strftime("%d %b %Y") %></h3>
|
|
36
|
+
<dl class="hrl-deflist">
|
|
37
|
+
<dt>CTC</dt><dd><%= hrl_rupees(upcoming[:annual_ctc]) %> a year</dd>
|
|
38
|
+
<dt>Gross</dt><dd><%= hrl_rupees(upcoming[:monthly_gross]) %> a month</dd>
|
|
39
|
+
<dt>In-hand before tax</dt><dd><%= hrl_rupees(upcoming[:in_hand]) %> a month</dd>
|
|
40
|
+
</dl>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% if @structure.nil? && @upcoming.empty? %>
|
|
43
|
+
<p class="hrl-card--empty">Your salary structure hasn't been set up yet.</p>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% if @past.any? %>
|
|
46
|
+
<h3 class="hrl-card__title">Earlier revisions</h3>
|
|
47
|
+
<% @past.each do |structure| %>
|
|
48
|
+
<p class="hrl-small">From <%= structure.effective_from.strftime("%d %b %Y") %> — <%= hrl_rupees(structure.breakup(on: structure.effective_from)[:annual_ctc]) %> a year CTC</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% end %>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
6
53
|
<section class="hrl-card">
|
|
7
54
|
<% if @slips.any? %>
|
|
8
55
|
<div class="hrl-table-wrap">
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<% @board.rows.each do |row| %>
|
|
30
30
|
<tr>
|
|
31
31
|
<td data-label="Name">
|
|
32
|
-
<span
|
|
32
|
+
<span><%= tag.strong hr_display_name(row.user), title: ("On leave: #{row.leave.date_range_label}" if row.leave) %><% if row.profile&.designation.present? %>
|
|
33
33
|
<span class="hrl-small hrl-muted"> · <%= row.profile.designation %></span><% end %></span>
|
|
34
34
|
</td>
|
|
35
35
|
<td data-label="Status"><%= hrl_team_status(row) %></td>
|
data/lib/hr_lite/version.rb
CHANGED