hr_lite 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 308cf87cc748758a71ff2bafc7cc301e7ff1fa3f0efc0e0b222e7e4e82e30161
4
- data.tar.gz: 8541bec2dbd4b8c6c1b895065a9580f65de22ce01d128c86c4bc52455aa2bdc0
3
+ metadata.gz: 36aafaef8b40a06e92550352b9b1b316d793185ffa871c2a872430c44a88d23c
4
+ data.tar.gz: 957a8842a0ce1fa20e200c9cd0795a9c75eeff204be0d39aa9198781fcfc13ed
5
5
  SHA512:
6
- metadata.gz: 7b6788e0da35e5695257ebf95e2a25b152a8c42d9659d449197995061b57220d34ae6503986f6aa80497cd55dc74e44d0ea2247126cf48924cead29d2c2f40f8
7
- data.tar.gz: 06d8209caa183f2092611e9d7613b12a7d1cb45e3333a9ad0b0d1027e4f5ed48e6845e9b6766e6ed8f1352ca537c8921bf85e5e23af9b49876b75021273f645b
6
+ metadata.gz: e7eeeb898e3798322fdf75539115586a9513b8c048edd51b89af45bba9cc37d7577e4645cf1b3c6707158b1c51d49195d2d803dddcab5dae2990b6607a6a8772
7
+ data.tar.gz: 74eb90b519e1ca6f7ece2261bbf02f4e0cc094ebbd6faf054eb1945af71a4d4c950551ce7d10c353b14e652bbe2af3ce9978d7af4a729882f3e87214ba76549d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.2] - 2026-10-05
11
+
12
+ No migration.
13
+
14
+ ### Changed
15
+
16
+ - **Leave team notice goes out on apply, by email too.** The whole team
17
+ (minus the applicant) gets the bell and an email the moment leave is
18
+ applied, not after approval. The reason is still left out. Hosts can
19
+ mute either channel with the `leave.team_notice` matrix row.
20
+ - **A blocked location holds the punch.** Browsers ask for location once;
21
+ after a "Don't allow" every later punch was filed without GPS and
22
+ flagged. The punch now waits and tells the employee to allow location,
23
+ then tap again. Timeout and no-GPS devices still punch (and flag).
24
+ - Team attendance board: "Fix / month" is now "Edit / half day", the
25
+ screen where HR changes a marked day to a half day.
26
+
27
+ ## [0.20.1] - 2026-10-03
28
+
29
+ Security fixes. No migration.
30
+
31
+ ### Upgrading
32
+
33
+ Document numbers are now encrypted with Active Record Encryption. Rows
34
+ saved before this release are plain text, so the host needs
35
+ `config.active_record.encryption.support_unencrypted_data = true` to keep
36
+ reading them, and should re-save each `HrLite::Document` once to encrypt
37
+ the old numbers.
38
+
39
+ ### Security
40
+
41
+ - **Nobody decides their own request.** A manager could approve their own
42
+ leave, comp-off and attendance fixes, Finance its own expense claims, and
43
+ anyone recording leave for someone else could record their own as
44
+ approved. HR could verify or reject its own documents. All refused now.
45
+ - **Nobody edits their own records from the admin screens.** HR rewriting
46
+ its own attendance, adjusting its own leave balance, or Finance verifying
47
+ its own tax declaration is refused.
48
+ - **Document numbers are encrypted.** Aadhaar, PAN and passport numbers
49
+ were stored in plain text and copied into audit diffs and the leadership
50
+ audit email. Loans join the money tier for audit mail.
51
+ - **A rejected tax declaration no longer lowers TDS.**
52
+ - **Payroll CSVs defuse formulas.** Register and report cells that open
53
+ with `=`, `+`, `-` or `@` are escaped, since names are staff-typed.
54
+ - **The payout register needs `payroll.export`**, not just
55
+ `payroll.manage`.
56
+ - **The admin overview respects reach.** A team-scoped manager sees only
57
+ the people they reach, not the whole company.
58
+ - **Expense receipts** take the same type and size rule as documents.
59
+
10
60
  ## [0.20.0] - 2026-10-01
11
61
 
12
62
  Salary as CTC, and who is out today. No migration.
@@ -6,7 +6,7 @@
6
6
  // <button type="submit">…</button>
7
7
  // <span data-hrl-geo-status hidden></span>
8
8
  // </form>
9
- // The punch NEVER blocks on GPS: denied/timeout/unavailable submit anyway
9
+ // A denied permission holds the punch; timeout/unavailable submit anyway
10
10
  // with geo_status set; the server flags them.
11
11
  (function () {
12
12
  "use strict";
@@ -45,7 +45,15 @@
45
45
  send("ok");
46
46
  },
47
47
  function (err) {
48
- send(err && err.code === 1 ? "denied" : "timeout");
48
+ // Browsers ask only once; a past "Don't allow" denies silently forever.
49
+ // Hold the punch so it is never filed (and flagged) without GPS.
50
+ if (err && err.code === 1) {
51
+ locating = false;
52
+ if (button) { button.disabled = false; button.removeAttribute("aria-busy"); }
53
+ if (label) label.textContent = "Location is blocked. Allow location for this site in your browser settings, then tap again.";
54
+ return;
55
+ }
56
+ send("timeout");
49
57
  },
50
58
  { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
51
59
  );
@@ -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
@@ -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
 
@@ -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
@@ -25,6 +25,7 @@ module HrLite
25
25
 
26
26
  before_validation :cache_days_count, on: :create
27
27
  after_create :notify_requested, unless: :recorded_by_hr?
28
+ after_create :notify_team
28
29
 
29
30
  scope :pending, -> { where(status: "pending") }
30
31
  scope :approved, -> { where(status: "approved") }
@@ -171,7 +172,6 @@ module HrLite
171
172
  return false if insufficient
172
173
 
173
174
  notify_decision("Leave approved")
174
- notify_team
175
175
  true
176
176
  end
177
177
 
@@ -247,8 +247,8 @@ module HrLite
247
247
  )
248
248
  end
249
249
 
250
- # Everyone should know a colleague will be away — bell + email to the
251
- # whole team (matrix row "leave.team_notice"; hosts can mute either
250
+ # Everyone should know a colleague will be away, the moment they apply —
251
+ # bell + email to the whole team (matrix row "leave.team_notice"; hosts can mute either
252
252
  # channel). Deliberately excludes the reason: dates are team-relevant,
253
253
  # the why is not.
254
254
  def notify_team
@@ -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
@@ -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
@@ -43,7 +43,7 @@
43
43
  <% if record&.regularized? %><span class="hrl-badge">Fixed</span><% end %>
44
44
  </td>
45
45
  <td data-label="">
46
- <a class="hrl-small" href="<%= hr_lite.admin_attendance_path(employee.id, date: @date, month: @date.strftime("%Y-%m")) %>">Fix / month</a>
46
+ <a class="hrl-small" href="<%= hr_lite.admin_attendance_path(employee.id, date: @date, month: @date.strftime("%Y-%m")) %>">Edit / half day</a>
47
47
  </td>
48
48
  </tr>
49
49
  <% end %>
@@ -51,7 +51,7 @@ module HrLite
51
51
  "resignation.withdrawn" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
52
52
  "employee.onboarded" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
53
53
  "payroll.draft_ready" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
54
- "leave.team_notice" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
54
+ "leave.team_notice" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
55
55
  "comp_off.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
56
56
  "comp_off.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
57
57
  "comp_off.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.20.0"
2
+ VERSION = "0.20.2"
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.20.0
4
+ version: 0.20.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha