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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +9 -0
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +2 -1
  5. data/app/controllers/hr_lite/admin/attendances_controller.rb +2 -1
  6. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +2 -1
  7. data/app/controllers/hr_lite/admin/documents_controller.rb +2 -0
  8. data/app/controllers/hr_lite/admin/expenses_controller.rb +2 -1
  9. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +1 -0
  10. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -2
  11. data/app/controllers/hr_lite/admin/overview_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +5 -1
  13. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +2 -1
  14. data/app/controllers/hr_lite/admin/reports_controller.rb +3 -1
  15. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +16 -2
  16. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +1 -0
  17. data/app/controllers/hr_lite/home_controller.rb +3 -0
  18. data/app/controllers/hr_lite/salary_slips_controller.rb +5 -0
  19. data/app/helpers/hr_lite/application_helper.rb +4 -1
  20. data/app/models/concerns/hr_lite/audited.rb +2 -1
  21. data/app/models/hr_lite/audit_log.rb +1 -1
  22. data/app/models/hr_lite/document.rb +3 -0
  23. data/app/models/hr_lite/expense.rb +8 -0
  24. data/app/models/hr_lite/salary_structure.rb +57 -0
  25. data/app/services/hr_lite/overview_query.rb +13 -7
  26. data/app/services/hr_lite/slip_builder.rb +3 -21
  27. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +4 -0
  28. data/app/views/hr_lite/home/index.html.erb +21 -0
  29. data/app/views/hr_lite/salary_slips/index.html.erb +47 -0
  30. data/app/views/hr_lite/team/show.html.erb +1 -1
  31. data/lib/hr_lite/version.rb +1 -1
  32. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d99d3408ecc4296bfb60310343f56df91495a48a6bc277881f6ebbd79a57de80
4
- data.tar.gz: 9286e4b08358dbbe9f360967a57b134aa0ee6d74841daf80310ad79b372ae7b2
3
+ metadata.gz: 0d52aa888b9029219bfde72b9d7eabcae22691d9bb5ec73ec6a924191cdf8ac2
4
+ data.tar.gz: b1e9c215421a30a9c521059c90349db86276f0e74f3ac4c7eb4a34a05ca2cd2b
5
5
  SHA512:
6
- metadata.gz: a96b511a5031812bf57b8b10314c1316eb29857f25f0e5dd4e454caea586bf88c3feff01180e3d5bdd8da693421c52524c9da93038740266c8bf9c079dca24ed
7
- data.tar.gz: dc3d6659e50a6421935556bb13db5a0749198e119b6bc01fa5a84415a913d26e4a38397c71f2e529c379fdc14663eb9afb8825c1f402e9723e7c1fb2ba933ba8
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
- def find_manageable_employee = employee_within("attendance.manage")
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
- def decidable = hr_scope(Expense.all, "expense.approve")
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
@@ -4,7 +4,7 @@ module HrLite
4
4
  SECTION_CAP = 10
5
5
 
6
6
  def index
7
- @query = OverviewQuery.new
7
+ @query = OverviewQuery.new(user_ids: hr_access.visible_user_ids("leave.view"))
8
8
  @kpis = @query.kpis
9
9
  end
10
10
  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
- if @structure.update(structure_params)
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
- saved_changes.except(*SKIPPED_ATTRIBUTES).to_h do |attr, (from, to)|
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
- def initialize(date: Date.current)
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
- return @profile.declared_annual_deductions if declaration.draft?
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><strong><%= hr_display_name(row.user) %></strong><% if row.profile&.designation.present? %>
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>
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.19.0"
2
+ VERSION = "0.20.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha