hr_lite 0.55.0 → 0.56.0

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: 901c6516adf77c589f4e570c477d7aa2a7b05f279e5ef9d205cf868796074939
4
- data.tar.gz: d3a6e968a40f6e5fe9d8503a174f8ce004c18ee6cf25218372e21a78d5fecc76
3
+ metadata.gz: 5deeac7b0eead75adc631a3fe868844a5db7712aaa8297089d6a7ab305c1c610
4
+ data.tar.gz: fd6b50aa2589eaba50c53703f3a8d4c22a96c61d469989045e1dc7113005a546
5
5
  SHA512:
6
- metadata.gz: 43c25d542b2eacd2af96e5ff8b281f608fb202be4c1c8ecdd4790b440843fb79ceb32596e8d641bb72beffc8c606dd558d65cd0bba5fbc4a860a8a59e5f24790
7
- data.tar.gz: 2e1af9569c33bce9ca0ea5ef790b855d397e6ebf6c88a656757001849f0d197ee8901c057d19de835d999d80005dab3fd6e505ef26829167d3be7f9a101c834c
6
+ metadata.gz: 14b6853b122a691166a5f2b884a52451d3893bff7b82d33df1ab5e69bfbf71dc00644fe07221588f914a2e7082b95e1164a64f7545d830ccb26c88792c82a701
7
+ data.tar.gz: 5dbf80c20826f0f3baaff056627d2bb7478914dfce0eb3e140976564e8d60fc4e4a98684fdf37b2cc3c77f5fbc5de5314b6048470fec9c33af7baaeb7053f06e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.56.0] - 2026-10-17
11
+
12
+ Owner decisions on the HRMS audit (#133–#138). **Two migrations**: the host runs `bin/rails db:migrate` and refreshes `db/schema.rb`.
13
+
14
+ - **Claims paid with payroll** (FB-5). "Pay with payroll" books an approved claim to a month. The claim is added to that month's slip as a reimbursement line. It is outside gross, so PF, ESI, PT and TDS do not change. Publishing the run marks the claim reimbursed. Booking or moving a claim after compute blocks the run until it is recomputed. A month whose run is finalized or published is refused. Claims marked reimbursed before this release are not picked up again.
15
+ - **Loan instalments never take more than the month's pay** (FB-4). The deduction is capped at what is left after every other deduction. Only the amount taken is booked, the rest stays owed, and the run shows a warning naming the person.
16
+ - **Re-enrol in a benefit after cover ends** (FB-17). The unique index on enrolments now covers only open rows (`ended_on IS NULL`), and the validation matches it. A new index on `work_mode_events.attendance_record_id` (PF-14).
17
+ - **Super Admin may change their own record** (SE-1). The edit is audited. HR and Leadership still cannot edit their own pay, profile or exit.
18
+ - **Menu** (NV-2): Tax checks, Team loans and Team benefits move from Pay to Admin.
19
+ - **Phone**: the Team loans and person-documents "Open" links are 44 px buttons.
20
+
10
21
  ## [0.55.0] - 2026-10-17
11
22
 
12
23
  The second pass of the HRMS audit of 0.54.0 covers bugs, security, performance and the remaining screen fixes (#115–#131). No migrations.
@@ -114,8 +114,8 @@ module HrLite
114
114
 
115
115
  private
116
116
 
117
- # Nobody edits or offboards their own record here (auto-approve would publish it); someone else does.
118
- def others_profile = EmployeeProfile.find(params[:id]).tap { |p| raise ActiveRecord::RecordNotFound if p.user_id == hr_current_user.id }
117
+ # Nobody but Super Admin edits or offboards their own record here (auto-approve would publish it).
118
+ def others_profile = EmployeeProfile.find(params[:id]).tap { |p| raise ActiveRecord::RecordNotFound if p.user_id == hr_current_user.id && !hr_superadmin? }
119
119
 
120
120
  def profile_params
121
121
  params.require(:employee_profile).permit(
@@ -34,9 +34,9 @@ module HrLite
34
34
  expense = decidable.find(params[:id])
35
35
  month = parse_month_param(params[:period_month])
36
36
  expense.reimburse!(actor: hr_current_user, period_month: month)
37
- redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
37
+ redirect_to admin_expenses_path, notice: "Will be paid with #{month.strftime('%B %Y')} payroll."
38
38
  rescue ActiveRecord::RecordInvalid
39
- redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
39
+ redirect_to admin_expenses_path, alert: "Only an approved claim can go on payroll, and only on a month not yet paid."
40
40
  end
41
41
 
42
42
  # The receipt the employee had to upload, for the person deciding; same rules as a document download.
@@ -38,10 +38,10 @@ module HrLite
38
38
 
39
39
  private
40
40
 
41
- # Nobody sets their own pay.
41
+ # Nobody but Super Admin sets their own pay (the owner has nobody above them).
42
42
  def set_profile
43
43
  @profile = EmployeeProfile.find(params[:employee_id])
44
- raise ActiveRecord::RecordNotFound if @profile.user_id == hr_current_user.id
44
+ raise ActiveRecord::RecordNotFound if @profile.user_id == hr_current_user.id && !hr_superadmin?
45
45
  end
46
46
 
47
47
  def structure_params
@@ -64,11 +64,7 @@ module HrLite
64
64
  { label: "Benefits", path: :benefits_path, match: [ "/benefits" ], if: -> { hr_can?("benefit.view") } },
65
65
  # HR approves payroll without running it; Super Admin runs it. One page.
66
66
  { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips", "/admin/payroll_line_items" ],
67
- if: -> { hr_superadmin? || (hr_can?("payroll.approve", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
68
- { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
69
- if: -> { hr_superadmin? || (hr_can?("tax.view", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
70
- { label: "Team loans", path: :admin_loans_path, match: [ "/admin/loans" ], if: SUPER },
71
- { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
67
+ if: -> { hr_superadmin? || (hr_can?("payroll.approve", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } }
72
68
  ] },
73
69
  { label: "People", match: [ "/people" ], tabs: [
74
70
  { label: "Team board", path: :team_path, match: [ "/team" ] },
@@ -99,7 +95,11 @@ module HrLite
99
95
  { label: "Statutory rates", path: :admin_statutory_rate_cards_path, match: [ "/admin/statutory_rate_cards" ], if: SUPER },
100
96
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ], if: -> { hr_can?("expense.approve", scope: :all) || %w[leave.view attendance.view profile.view].any? { |key| hr_can?(key, scope: :team) } } },
101
97
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ], if: -> { hr_can?("asset.manage", scope: :all) } },
102
- { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD }
98
+ { label: "Audit trail", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ], if: LEAD },
99
+ { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
100
+ if: -> { hr_superadmin? || (hr_can?("tax.view", scope: :all) && instance_exec(&MANAGE_OR_CLAIMS)) } },
101
+ { label: "Team loans", path: :admin_loans_path, match: [ "/admin/loans" ], if: SUPER },
102
+ { label: "Team benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ], if: MANAGE }
103
103
  ] }
104
104
  ].freeze
105
105
 
@@ -9,7 +9,9 @@ module HrLite
9
9
  belongs_to :user, class_name: HrLite.config.user_class
10
10
 
11
11
  validates :enrolled_on, presence: true
12
- validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
12
+ # Mirrors the partial unique index: ended cover is history, not a clash.
13
+ validates :user_id, uniqueness: { scope: :benefit_id, conditions: -> { where(ended_on: nil) },
14
+ message: "is already enrolled" }, unless: :ended_on?
13
15
  validates :dependants, numericality: { greater_than_or_equal_to: 0 }
14
16
  validate :ended_after_it_started
15
17
 
@@ -53,21 +53,26 @@ module HrLite
53
53
  settle!("rejected", actor, note)
54
54
  end
55
55
 
56
- # Paid out with a payroll month. Kept separate from `approved` because
57
- # agreeing to pay somebody and actually paying them are different days,
58
- # and the person waiting cares about the second one.
56
+ # Booked onto a payroll month: that month's slip pays it, and publishing
57
+ # the run marks it reimbursed. Agreeing to pay somebody and actually
58
+ # paying them are different days, and the person waiting cares about the second.
59
59
  def reimburse!(actor:, period_month:)
60
+ months = [ reimbursed_in, period_month.beginning_of_month ].compact.uniq # moving it touches both runs
60
61
  raise ActiveRecord::RecordInvalid.new(self), "not approved" unless approved?
62
+ raise ActiveRecord::RecordInvalid.new(self), "month already paid" if PayrollRun.exists?(period_month: months, status: %w[finalized published])
61
63
 
62
64
  transaction do
63
- update!(status: "reimbursed", reimbursed_in: period_month.beginning_of_month)
65
+ update!(reimbursed_in: months.last)
64
66
  AuditLog.record!(action: "expense.reimbursed", subject: self, actor: actor,
65
67
  changes: { "employee" => HrLite.display_name(user),
66
68
  "period" => period_month.strftime("%B %Y") })
67
69
  end
70
+ PayrollRun.where(period_month: months, status: "review").find_each do |run|
71
+ run.update!(warnings: run.warnings + [ "#{HrLite.display_name(user)}'s #{category.name} claim changed after compute — recompute" ])
72
+ end
68
73
  Notifications.publish(
69
74
  "expense.reimbursed",
70
- title: "Your #{category.name} claim was reimbursed with #{period_month.strftime('%B %Y')} payroll",
75
+ title: "Your #{category.name} claim will be paid with #{period_month.strftime('%B %Y')} payroll",
71
76
  path: "/expenses", bell_to: [ user ], email_to: [ user ]
72
77
  )
73
78
  true
@@ -137,6 +137,9 @@ module HrLite
137
137
  transaction do
138
138
  update!(status: "published", published_at: Time.current, published_by_id: actor&.id)
139
139
  audit!("payroll.published", actor, "slips" => salary_slips.count)
140
+ # The claims these slips carry are paid now.
141
+ Expense.awaiting_reimbursement.where(reimbursed_in: period_month, user_id: salary_slips.select(:user_id))
142
+ .update_all(status: "reimbursed", updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
140
143
  end
141
144
 
142
145
  # config.slip_release_day holds the employee notice until the slip opens
@@ -50,6 +50,9 @@ module HrLite
50
50
  end
51
51
 
52
52
  slip.assign_attributes(attributes)
53
+ if slip.deductions_rows.any? { |row| row.dig("meta", "carried_forward") }
54
+ warnings << "#{HrLite.display_name(user)}'s loan instalment cut (pay too low); the rest is carried forward on the loan"
55
+ end
53
56
  slip.save!
54
57
  keep_ids << slip.id
55
58
  end
@@ -71,10 +71,8 @@ module HrLite
71
71
  deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive?
72
72
 
73
73
  deductions.concat(extra_deductions)
74
- loan = loan_instalment
75
- deductions << loan if loan
76
74
 
77
- fy = SalarySlip.fy_to_date(@user, @run.period_month)
75
+ fy =SalarySlip.fy_to_date(@user, @run.period_month)
78
76
  tds = Calculators::Tds.call(
79
77
  regime: tax_regime,
80
78
  structure_monthly_gross: @structure.monthly_gross,
@@ -90,9 +88,16 @@ module HrLite
90
88
  rates: @rates[:income_tax],
91
89
  override: @tds_override
92
90
  )
91
+ loan = loan_instalment(gross_earned - deductions.sum(BigDecimal(0)) { |row| row[:amount] } - tds.monthly)
92
+ deductions << loan if loan
93
93
  deductions << { code: "tds", label: "Income tax (TDS)", amount: tds.monthly } if tds.monthly.positive?
94
94
 
95
95
  total_deductions = deductions.sum(BigDecimal(0)) { |row| row[:amount] }
96
+ # Claims booked onto this month: paid back with the salary but not
97
+ # salary, so outside gross and every PF, ESI, PT and TDS base.
98
+ claims = Expense.awaiting_reimbursement.where(user_id: @user.id, reimbursed_in: @run.period_month).includes(:category).map do |claim|
99
+ { code: "reimbursement", label: "#{claim.category.name} claim (reimbursement, outside gross)", amount: claim.amount }
100
+ end
96
101
 
97
102
  {
98
103
  period_month: @run.period_month,
@@ -104,7 +109,7 @@ module HrLite
104
109
  lop_days: Money.d(summary[:lop_days]),
105
110
  lop_override: @lop_override,
106
111
  tds_override: @tds_override,
107
- earnings: serialize_rows(earnings),
112
+ earnings: serialize_rows(earnings + claims),
108
113
  deductions: serialize_rows(deductions),
109
114
  employer_costs: employer_costs.transform_values { |v| v.to_s("F") }.to_json,
110
115
  tax_details: tds.details.to_json,
@@ -113,7 +118,7 @@ module HrLite
113
118
  # A heavy-LOP month can leave TDS (projected from the full structure)
114
119
  # larger than what was actually earned. Nobody is paid a negative
115
120
  # salary; PayrollRunProcessor warns when this floor bites.
116
- net_pay: [ gross_earned - total_deductions, BigDecimal(0) ].max,
121
+ net_pay: [ gross_earned + claims.sum(BigDecimal(0)) { |row| row[:amount] } - total_deductions, BigDecimal(0) ].max,
117
122
  computed_at: Time.current
118
123
  }
119
124
  end
@@ -172,13 +177,16 @@ module HrLite
172
177
  # The month's loan instalment, as one deduction line. Computed here and
173
178
  # BOOKED only when the run is finalized — a draft is recomputed freely,
174
179
  # and booking at compute would take a repayment on every pass.
175
- def loan_instalment
176
- total = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
180
+ # Never more than `room` (pay left after every other deduction); the rest stays owed.
181
+ def loan_instalment(room)
182
+ due = Loan.active.where(user_id: @user.id).sum(BigDecimal(0)) do |loan|
177
183
  loan.instalment_for(@run.period_month)
178
184
  end
179
- return nil unless total.positive?
185
+ return nil unless due.positive?
180
186
 
181
- { code: "loan_repayment", label: "Loan repayment", amount: total }
187
+ amount = room.clamp(BigDecimal(0), due)
188
+ row = { code: "loan_repayment", label: "Loan repayment", amount: amount }
189
+ amount < due ? row.merge(meta: { carried_forward: (due - amount).to_s("F") }) : row
182
190
  end
183
191
 
184
192
  # Months left in the Indian FY (Apr–Mar) including the run month itself:
@@ -32,7 +32,7 @@
32
32
  </td>
33
33
  <td data-label="">
34
34
  <% if doc.readable_by?(hr_current_user) && doc.file.attached? %>
35
- <a class="hrl-small" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
35
+ <a class="hrl-btn" href="<%= hr_lite.download_document_path(doc) %>">Open</a>
36
36
  <% end %>
37
37
  </td>
38
38
  </tr>
@@ -1,7 +1,7 @@
1
1
  <% content_for(:page_title) { @profile.employee_code } %>
2
2
  <% content_for(:tabrow) { render "hr_lite/shared/person_tabs", user: @profile.user } %>
3
3
  <% content_for(:back, "Employees") %>
4
- <% own = @profile.user_id == hr_current_user.id # your own pay and record are for someone else to change %>
4
+ <% own = @profile.user_id == hr_current_user.id && !hr_superadmin? # your own pay and record are for someone else to change, unless you are Super Admin %>
5
5
  <div class="hrl-page__head">
6
6
  <h1 class="hrl-page__title"><%= hr_display_name(@profile.user) %> <span class="hrl-muted">(<%= @profile.employee_code %>)</span></h1>
7
7
  <div class="hrl-page__actions">
@@ -42,8 +42,8 @@
42
42
  <% elsif expense.approved? %>
43
43
  <%= form_with url: hr_lite.reimburse_admin_expense_path(expense), method: :post do %>
44
44
  <%= label_tag "reimburse-month-#{expense.id}", "Paid with the payroll of", class: "hrl-sr-only" %>
45
- <%= month_field_tag :period_month, Date.current.strftime("%Y-%m"), id: "reimburse-month-#{expense.id}", required: true, class: "hrl-input" %>
46
- <%= submit_tag "Mark reimbursed", class: "hrl-btn" %>
45
+ <%= month_field_tag :period_month, (expense.reimbursed_in || Date.current).strftime("%Y-%m"), id: "reimburse-month-#{expense.id}", required: true, class: "hrl-input" %>
46
+ <%= submit_tag "Pay with payroll", class: "hrl-btn" %>
47
47
  <% end %>
48
48
  <% end %>
49
49
  </td>
@@ -31,7 +31,7 @@
31
31
  <td data-label="Instalment" class="hrl-num"><%= hrl_money(loan.monthly_instalment) %></td>
32
32
  <td data-label="Outstanding" class="hrl-num"><%= hrl_money(loan.outstanding) %></td>
33
33
  <td data-label="">
34
- <a class="hrl-small" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
34
+ <a class="hrl-btn" href="<%= hr_lite.admin_loan_path(loan) %>">Open</a>
35
35
  </td>
36
36
  </tr>
37
37
  <% end %>
@@ -0,0 +1,8 @@
1
+ class MakeHrLiteBenefitEnrolmentsUniqueWhileOpen < ActiveRecord::Migration[8.1]
2
+ # One open enrolment per person per plan; ended ones stay as history.
3
+ def change
4
+ remove_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true, if_exists: true
5
+ add_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true, where: "ended_on IS NULL",
6
+ name: "index_hr_lite_benefit_enrolments_one_open", if_not_exists: true
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class AddAttendanceRecordIdIndexToHrLiteWorkModeEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_index :hr_lite_work_mode_events, :attendance_record_id, if_not_exists: true
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.55.0"
2
+ VERSION = "0.56.0"
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.55.0
4
+ version: 0.56.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -505,6 +505,8 @@ files:
505
505
  - db/migrate/20261016090000_add_penalty_leave_type_to_hr_lite_settings.rb
506
506
  - db/migrate/20261016091000_create_hr_lite_penalty_removals.rb
507
507
  - db/migrate/20261016100000_add_hours_allowance_repeat_to_hr_lite_employee_profiles.rb
508
+ - db/migrate/20261017161441_make_hr_lite_benefit_enrolments_unique_while_open.rb
509
+ - db/migrate/20261017161443_add_attendance_record_id_index_to_hr_lite_work_mode_events.rb
508
510
  - lib/generators/hr_lite/install/install_generator.rb
509
511
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
510
512
  - lib/generators/hr_lite/install/templates/initializer.rb