hr_lite 0.25.0 → 0.27.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: 3323d772472049e0607345bdd5443230a8976239aae71e4ebc2bb1dcb414380f
4
- data.tar.gz: 1f83df9c391642235c7da41b913757564b2ad9d60069c52ff06a6143fef18d06
3
+ metadata.gz: 8d6e999438a679338ca82e170c4e71ac3519476913ce34b86d5783c3a0f97671
4
+ data.tar.gz: 9a3ef8a6d6a6451f0563ff0f99979460d958e3d47f594d0e6de3b916cc4e8b41
5
5
  SHA512:
6
- metadata.gz: d64948ae1e08cdaed32254dcc70541c98cf7ed4daa0c5726b0977c1eaf615953bc267313df369fc7fc53a0f38df595f52c6f584df8b1ddd98a7bd9ecf201e61f
7
- data.tar.gz: ff03f9c30cad45019ccc7404d5fffffcaf0240b63b9028ee8ddcaff4f5dfd1e0030906b506a07cd716afd2f70e44e13ef296626691478d36bbbab4b316a8a813
6
+ metadata.gz: 953c47cc46554a8e953f0d125c5878d1d5b03344673f0e706d4c11718a410157dce3652cc3e0cbf414bd88989c13ae3c55f5d09b6fbc885ceb6447672a703583
7
+ data.tar.gz: ccaae3351ea83e10bb2b25c46966713a9cfad1a98909b0409ec286a9e64644293794675fb4c0ae5a9adac891a79546ac6929f996eb4a5f61c57a0f0b4d2c9cf7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-10-10
11
+
12
+ - HR and admins can fix leave they recorded (type, dates, half day) at any time, past dates included, without cancelling it. A reason is required; it is kept as the decision note and in the audit log (`leave.corrected`), and the balance and loss-of-pay split are worked out again.
13
+
14
+ ## [0.26.0] - 2026-10-10
15
+
16
+ Two migrations: `hr_lite_ca_reviews`, and a data migration creating the CA role and giving Super Admin the new `payroll.verify` permission. Schedule `CaReviewReminderJob` weekly and `CaReviewOverdueJob` on 31 March.
17
+
18
+ ### Added
19
+ - CA year-end review: a new **CA** role for an outside chartered accountant (reads every slip, salary structure and tax declaration with its proofs; verifies; nothing else). CA logins without an HR profile are not staff — `HrLite.employees` leaves them out, so attendance, payroll, team screens and broadcasts never count them.
20
+ - "CA review" (`/admin/ca_reviews`): progress for the financial year (slips and declarations verified, flags open), tabs for salary slips and tax declarations, filters (to review, flagged, verified, resolved), Verify, Flag with a required note, and "Verify all shown". A flag tells approvers and payroll (`ca.flagged`); they fix it the normal way and Mark resolved with a note. A published slip is never edited. `HrLite::CaReview`.
21
+ - `CaReviewReminderJob` (weekly, acts only in March) and `CaReviewOverdueJob` (31 March): what is left, to the CA reviewers and then to payroll and HR.
22
+ - A CA (`payroll.view`, all) can open any slip read-only.
23
+
10
24
  ## [0.25.0] - 2026-10-10
11
25
 
12
26
  Three migrations: `senior` and `landlord_pan` (encrypted) on declaration lines; a data migration adding the FY 2026-27 and October 2026 rate cards to installs that already have cards; a data migration giving HR `tax.view` and `tax.manage` (all).
data/README.md CHANGED
@@ -382,6 +382,8 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
382
382
  |---|---|---|
383
383
  | `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
384
384
  | `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify the approvers (`payroll.approve`) and payroll operators |
385
+ | `HrLite::CaReviewReminderJob` | weekly (it acts only in March) | Tell CA reviewers how many slips and declarations are still unverified before 31 March |
386
+ | `HrLite::CaReviewOverdueJob` | 31 March, evening | Tell payroll and HR what the CA has not verified, and any flags left open |
385
387
  | `HrLite::PayrollAutoApproveJob` | monthly, on the 3rd | Approve and publish last month's run if nobody finished approving it — unless it has blocking warnings (no salary structure, no PT slabs, a stale or unverified statutory card), when the approvers are told it is overdue instead |
386
388
  | `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
387
389
  | `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
@@ -0,0 +1,76 @@
1
+ module HrLite
2
+ module Admin
3
+ # The CA's year-end pass over every slip and declaration. Reviewers
4
+ # (payroll.verify) verify or flag; approvers and payroll resolve flags.
5
+ class CaReviewsController < BaseController
6
+ skip_before_action :require_operations_access!
7
+ before_action -> { require_any!("payroll.verify", "payroll.approve", "payroll.manage") }, only: :index
8
+ before_action -> { require_any!("payroll.verify") }, only: %i[verify flag verify_all]
9
+ before_action -> { require_any!("payroll.approve", "payroll.manage") }, only: :resolve
10
+
11
+ def index
12
+ @fy = FinancialYear.start_for(params[:fy].present? ? parse_date_param(params[:fy]) : Date.current)
13
+ @tab = params[:tab] == "declarations" ? "declarations" : "slips"
14
+ @status = params[:status].presence_in(%w[unreviewed verified flagged resolved])
15
+ scope = @tab == "slips" ? CaReview.slips_for(@fy).includes(:user, :payroll_run).order("hr_lite_payroll_runs.period_month", :user_id) : CaReview.declarations_for(@fy).includes(:user).order(:user_id)
16
+ type = scope.klass.name
17
+ reviewed = CaReview.where(subject_type: type)
18
+ scope = if @status == "unreviewed"
19
+ scope.where.not(id: reviewed.select(:subject_id))
20
+ elsif @status
21
+ scope.where(id: reviewed.where(status: @status).select(:subject_id))
22
+ else
23
+ scope
24
+ end
25
+ @rows = paginate(scope)
26
+ @reviews = reviewed.where(subject_id: @rows.map(&:id)).index_by(&:subject_id)
27
+ @left = CaReview.outstanding(@fy)
28
+ @totals = { "slips" => CaReview.slips_for(@fy).count, "declarations" => CaReview.declarations_for(@fy).count }
29
+ end
30
+
31
+ def verify
32
+ CaReview.verify!(subject, actor: hr_current_user)
33
+ redirect_back_to_list notice: "Verified."
34
+ end
35
+
36
+ def flag
37
+ return redirect_back_to_list(alert: "Say what is wrong — HR has to know what to fix.") if params[:note].blank?
38
+
39
+ CaReview.flag!(subject, actor: hr_current_user, note: params[:note])
40
+ redirect_back_to_list notice: "Flagged — HR and payroll have been told."
41
+ end
42
+
43
+ # "Verify all shown": the unreviewed rows on the page the CA was looking at.
44
+ def verify_all
45
+ model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
46
+ reviewed = CaReview.where(subject_type: model.name, subject_id: params[:ids]).pluck(:subject_id)
47
+ model.where(id: Array(params[:ids]) - reviewed.map(&:to_s)).find_each { |s| CaReview.verify!(s, actor: hr_current_user) }
48
+ redirect_back_to_list notice: "Verified everything shown."
49
+ end
50
+
51
+ def resolve
52
+ CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
53
+ redirect_back_to_list notice: "Marked resolved."
54
+ rescue ActiveRecord::RecordInvalid
55
+ redirect_back_to_list alert: "Only a flagged item can be resolved."
56
+ end
57
+
58
+ private
59
+
60
+ def require_any!(*keys)
61
+ return if keys.any? { |key| hr_can?(key, scope: :all) }
62
+
63
+ hr_require_permission!(keys.first, scope: :all)
64
+ end
65
+
66
+ def subject
67
+ model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
68
+ model.find(params[:subject_id])
69
+ end
70
+
71
+ def redirect_back_to_list(**flash)
72
+ redirect_to admin_ca_reviews_path(params.permit(:fy, :tab, :status, :page).to_h), **flash
73
+ end
74
+ end
75
+ end
76
+ end
@@ -37,6 +37,23 @@ module HrLite
37
37
  render :new, status: :unprocessable_entity
38
38
  end
39
39
 
40
+ # Leave HR recorded is fixed in place, any time, with a reason.
41
+ def edit
42
+ @request = find_recorded
43
+ end
44
+
45
+ def update
46
+ @request = find_recorded
47
+ reason = params[:correction_reason].to_s.strip
48
+ @request.errors.add(:base, "A reason is required to fix this leave") if reason.blank?
49
+ attrs = params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day_part)
50
+ if reason.present? && @request.correct!(actor: hr_current_user, attrs: attrs, reason: reason)
51
+ return redirect_to(admin_leave_request_path(@request), notice: "Leave fixed.")
52
+ end
53
+
54
+ render :edit, status: :unprocessable_entity
55
+ end
56
+
40
57
  def approve
41
58
  request = find_decidable
42
59
  if request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
@@ -91,6 +108,8 @@ module HrLite
91
108
 
92
109
  def find_decidable = decidable.find(params[:id])
93
110
 
111
+ def find_recorded = decidable.approved.where.not(created_by_id: nil).where("created_by_id <> user_id").find(params[:id])
112
+
94
113
  def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve").where.not(user_id: hr_current_user.id)
95
114
  end
96
115
  end
@@ -1,6 +1,10 @@
1
1
  module HrLite
2
2
  module Admin
3
3
  class SalarySlipsController < SuperadminController
4
+ # A CA reads a slip to verify it (payroll.view, all) without running payroll.
5
+ skip_before_action :require_money_access!, only: :show
6
+ before_action -> { hr_require_permission!("payroll.view", scope: :all) }, only: :show
7
+
4
8
  def show
5
9
  @slip = SalarySlip.includes(:payroll_run).find(params[:id])
6
10
  @profile = @slip.user_profile
@@ -18,6 +18,8 @@ module HrLite
18
18
  { label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
19
19
  { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
20
20
  { label: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
21
+ { label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ], group: "Company",
22
+ if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } },
21
23
  { label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
22
24
  { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
23
25
  { label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
@@ -0,0 +1,19 @@
1
+ module HrLite
2
+ # Schedule on 31 March evening: anything the CA has not verified, or a flag
3
+ # nobody resolved, goes to payroll and HR as overdue.
4
+ class CaReviewOverdueJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(on: Date.current)
8
+ left = CaReview.outstanding(FinancialYear.start_for(on))
9
+ return if left.values.sum.zero?
10
+
11
+ people = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
12
+ Notifications.publish(
13
+ "ca.overdue",
14
+ title: "CA review overdue: #{left['slips']} slips, #{left['declarations']} declarations unverified, #{left['flagged']} flags open",
15
+ path: "/admin/ca_reviews?status=unreviewed", bell_to: people, email_to: people
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ # Schedule weekly in March: tells the CA reviewers what is left before 31 March.
3
+ class CaReviewReminderJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(on: Date.current)
7
+ return unless on.month == 3
8
+
9
+ left = CaReview.outstanding(FinancialYear.start_for(on))
10
+ return if left.values.sum.zero?
11
+
12
+ reviewers = HrLite.users_holding("payroll.verify", scope: :all).to_a
13
+ Notifications.publish(
14
+ "ca.reminder",
15
+ title: "CA review: #{left['slips']} slips and #{left['declarations']} declarations left before 31 March" \
16
+ "#{", #{left['flagged']} flagged" if left['flagged'].positive?}",
17
+ path: "/admin/ca_reviews", bell_to: reviewers, email_to: reviewers
18
+ )
19
+ end
20
+ end
21
+ end
@@ -15,7 +15,7 @@ module HrLite
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
17
  HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
18
- HrLite::PerformancePlan
18
+ HrLite::PerformancePlan HrLite::CaReview
19
19
  ].freeze
20
20
 
21
21
  scope :recent, -> { order(created_at: :desc) }
@@ -0,0 +1,66 @@
1
+ module HrLite
2
+ # A chartered accountant's year-end mark on one salary slip or tax
3
+ # declaration. Verifying is final; a flag says what is wrong and stays open
4
+ # until HR or payroll fixes it the normal way (a correcting line on a later
5
+ # slip, or reopening the declaration) and resolves it. The slip itself is
6
+ # never edited.
7
+ class CaReview < ApplicationRecord
8
+ include Audited
9
+
10
+ SUBJECTS = %w[HrLite::SalarySlip HrLite::TaxDeclaration].freeze
11
+ STATUSES = %w[verified flagged resolved].freeze
12
+
13
+ belongs_to :subject, polymorphic: true
14
+ belongs_to :reviewed_by, class_name: HrLite.config.user_class
15
+ belongs_to :resolved_by, class_name: HrLite.config.user_class, optional: true
16
+
17
+ validates :subject_type, inclusion: { in: SUBJECTS }
18
+ validates :status, inclusion: { in: STATUSES }
19
+ validates :note, presence: true, if: -> { status == "flagged" }
20
+
21
+ # What the CA reviews for a financial year: every published slip, and
22
+ # every declaration somebody actually filed.
23
+ def self.slips_for(fy)
24
+ SalarySlip.joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published", period_month: fy...fy.next_year })
25
+ end
26
+
27
+ def self.declarations_for(fy) = TaxDeclaration.where(financial_year: fy).where.not(status: "draft")
28
+
29
+ # Still to do before 31 March: never reviewed, or flagged and not resolved.
30
+ def self.outstanding(fy)
31
+ { "slips" => slips_for(fy), "declarations" => declarations_for(fy) }.transform_values do |scope|
32
+ done = where(subject: scope, status: %w[verified resolved]).count
33
+ scope.count - done
34
+ end.merge("flagged" => where(subject_type: SUBJECTS, status: "flagged").count)
35
+ end
36
+
37
+ def self.verify!(subject, actor:)
38
+ review = find_or_initialize_by(subject: subject)
39
+ review.update!(status: "verified", note: nil, reviewed_by_id: actor.id, reviewed_at: Time.current)
40
+ review
41
+ end
42
+
43
+ def self.flag!(subject, actor:, note:)
44
+ review = find_or_initialize_by(subject: subject)
45
+ review.update!(status: "flagged", note: note.to_s.strip, reviewed_by_id: actor.id, reviewed_at: Time.current,
46
+ resolved_by_id: nil, resolved_at: nil, resolution_note: nil)
47
+ people = (HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a)
48
+ Notifications.publish("ca.flagged", title: "CA flagged #{review.subject_label}", body: review.note,
49
+ path: "/admin/ca_reviews?status=flagged", bell_to: people, email_to: people)
50
+ review
51
+ end
52
+
53
+ def resolve!(actor:, note:)
54
+ raise ActiveRecord::RecordInvalid.new(self), "not flagged" unless status == "flagged"
55
+
56
+ update!(status: "resolved", resolved_by_id: actor.id, resolved_at: Time.current, resolution_note: note.to_s.strip.presence)
57
+ end
58
+
59
+ def subject_label
60
+ case subject
61
+ when SalarySlip then "#{HrLite.display_name(subject.user)}'s #{subject.period_month.strftime('%B %Y')} slip"
62
+ else "#{HrLite.display_name(subject.user)}'s #{subject.label} tax declaration"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -123,6 +123,24 @@ module HrLite
123
123
  true
124
124
  end
125
125
 
126
+ # HR fixes leave it recorded — any time, even past dates — without a
127
+ # cancel. Re-run as a fresh recording so the create checks and the
128
+ # balance / loss-of-pay split are worked out exactly as the first time.
129
+ def correct!(actor:, attrs:, reason:)
130
+ fixed = false
131
+ transaction do
132
+ assign_attributes(attrs.merge(status: "pending", paid_days: nil, half_day: false))
133
+ raise ActiveRecord::Rollback unless valid?(:create) && save(validate: false)
134
+
135
+ audit!("corrected", actor, reason)
136
+ fixed = approve_outright!(actor: actor, note: "Corrected: #{reason}") || raise(ActiveRecord::Rollback)
137
+ end
138
+ errors.add(:base, "Not enough #{leave_type.name} balance") unless fixed || errors.any?
139
+ fixed
140
+ end
141
+
142
+ def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
143
+
126
144
  def date_range_label
127
145
  if start_date == end_date
128
146
  "#{start_date.strftime('%d %b')}#{half_day ? " (#{half_day_part.present? ? "#{half_day_part} half" : 'half day'})" : ''}"
@@ -230,8 +248,6 @@ module HrLite
230
248
  )
231
249
  end
232
250
 
233
- def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
234
-
235
251
  # The balance covers whole half-days only, never less than nothing.
236
252
  def covered_by(available) = [ (BigDecimal(available.to_s) * 2).floor / BigDecimal(2), 0 ].max
237
253
 
@@ -12,6 +12,9 @@ module HrLite
12
12
  FINANCE = "Finance".freeze
13
13
  LEADERSHIP = "Leadership".freeze
14
14
  SUPER_ADMIN = "Super Admin".freeze
15
+ # An outside chartered accountant: reads pay and tax to verify them at
16
+ # year end. Not an employee — see HrLite.employees.
17
+ CA = "CA".freeze
15
18
 
16
19
  has_many :role_grants, dependent: :destroy
17
20
  has_many :role_assignments, dependent: :destroy
@@ -0,0 +1,94 @@
1
+ <% content_for(:page_title) { "CA review" } %>
2
+ <% fy_label = HrLite::FinancialYear.label(@fy) %>
3
+ <% reviewer = hr_can?("payroll.verify", scope: :all) %>
4
+ <% resolver = hr_can?("payroll.approve", scope: :all) || hr_can?("payroll.manage", scope: :all) %>
5
+ <% keep = ->(extra = {}) { { fy: @fy, tab: @tab, status: @status }.merge(extra).compact } %>
6
+ <div class="hrl-page__head">
7
+ <h1 class="hrl-page__title">CA review — FY <%= fy_label %></h1>
8
+ </div>
9
+ <p class="hrl-small hrl-muted">Every published salary slip and every filed tax declaration, verified by the CA before 31 March <%= @fy.year + 1 %>. A flag goes to HR and payroll; the fix is a correcting line on a later slip or reopening the declaration — a published slip is never edited.</p>
10
+
11
+ <div class="hrl-kpis">
12
+ <% %w[slips declarations].each do |kind| %>
13
+ <% total = @totals[kind] %>
14
+ <% done = total - @left[kind] %>
15
+ <div class="hrl-kpi">
16
+ <div class="hrl-kpi__value"><%= done %> / <%= total %></div>
17
+ <div class="hrl-kpi__label"><%= kind == "slips" ? "Salary slips" : "Tax declarations" %> verified</div>
18
+ <progress class="hrl-tax-cap" max="<%= [ total, 1 ].max %>" value="<%= done %>" aria-hidden="true"></progress>
19
+ </div>
20
+ <% end %>
21
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @left["flagged"] %></div><div class="hrl-kpi__label">Flags open</div></div>
22
+ </div>
23
+
24
+ <div class="hrl-row hrl-row--mb" role="tablist">
25
+ <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "slips" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "slips", status: @status)) %>" aria-current="<%= @tab == "slips" ? "page" : nil %>">Salary slips</a>
26
+ <a class="hrl-btn <%= "hrl-btn--primary" if @tab == "declarations" %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(tab: "declarations", status: @status)) %>" aria-current="<%= @tab == "declarations" ? "page" : nil %>">Tax declarations</a>
27
+ </div>
28
+ <div class="hrl-row hrl-row--mb">
29
+ <% [ [ nil, "All" ], [ "unreviewed", "To review" ], [ "flagged", "Flagged" ], [ "verified", "Verified" ], [ "resolved", "Resolved" ] ].each do |value, label| %>
30
+ <a class="hrl-btn <%= "hrl-btn--primary" if @status == value %>" href="<%= hr_lite.admin_ca_reviews_path(keep.(status: value)) %>"><%= label %></a>
31
+ <% end %>
32
+ </div>
33
+
34
+ <section class="hrl-card">
35
+ <% if @rows.empty? %>
36
+ <p class="hrl-card--empty">Nothing here.</p>
37
+ <% else %>
38
+ <% subject_type = @rows.first.class.name %>
39
+ <% if reviewer && @rows.any? { |row| @reviews[row.id].nil? } %>
40
+ <%= button_to "Verify all shown", hr_lite.verify_all_admin_ca_reviews_path, method: :post, class: "hrl-btn",
41
+ params: keep.(subject_type: subject_type, ids: @rows.map(&:id)),
42
+ form: { data: { turbo_confirm: "Verify every unreviewed item on this page?" } } %>
43
+ <% end %>
44
+ <ul class="hrl-feed">
45
+ <% @rows.each do |row| %>
46
+ <% review = @reviews[row.id] %>
47
+ <li class="hrl-feed__item">
48
+ <div class="hrl-feed__body">
49
+ <div class="hrl-row">
50
+ <% if row.is_a?(HrLite::SalarySlip) %>
51
+ <a class="hrl-card__link" href="<%= hr_lite.admin_salary_slip_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.period_month.strftime("%B %Y") %></a>
52
+ <span class="hrl-small hrl-muted">net <%= hrl_money(row.net_pay) %></span>
53
+ <% else %>
54
+ <a class="hrl-card__link" href="<%= hr_lite.admin_tax_declaration_path(row) %>"><%= hr_display_name(row.user) %> — <%= row.label %></a>
55
+ <span class="hrl-small hrl-muted"><%= row.regime.humanize %> regime · <%= row.status.humanize %></span>
56
+ <% end %>
57
+ <span class="hrl-right">
58
+ <% badge = { "verified" => [ "hrl-badge--ok", "Verified" ], "flagged" => [ "hrl-badge--bad", "Flagged" ],
59
+ "resolved" => [ "", "Resolved" ] }.fetch(review&.status, [ "hrl-badge--muted", "To review" ]) %>
60
+ <span class="hrl-badge <%= badge.first %>"><%= badge.last %></span>
61
+ </span>
62
+ </div>
63
+ <% if review&.note.present? %><p class="hrl-small">CA: <%= review.note %></p><% end %>
64
+ <% if review&.resolution_note.present? %><p class="hrl-small hrl-muted">Resolved by <%= hr_display_name(review.resolved_by) %>: <%= review.resolution_note %></p><% end %>
65
+ <div class="hrl-row hrl-mt">
66
+ <% if reviewer && review&.status != "verified" %>
67
+ <%= button_to "Verify", hr_lite.verify_admin_ca_reviews_path, method: :post, class: "hrl-btn",
68
+ params: keep.(subject_type: subject_type, subject_id: row.id) %>
69
+ <% end %>
70
+ <% if reviewer %>
71
+ <details>
72
+ <summary class="hrl-btn">Flag…</summary>
73
+ <%= form_with url: hr_lite.flag_admin_ca_reviews_path, method: :post, local: true do %>
74
+ <% keep.(subject_type: subject_type, subject_id: row.id).each do |key, value| %><%= hidden_field_tag key, value %><% end %>
75
+ <div class="hrl-field"><%= label_tag "note_#{row.id}", "What is wrong" %><%= text_area_tag :note, nil, id: "note_#{row.id}", required: true, rows: 2 %></div>
76
+ <%= submit_tag "Flag it", class: "hrl-btn hrl-btn--primary" %>
77
+ <% end %>
78
+ </details>
79
+ <% end %>
80
+ <% if resolver && review&.status == "flagged" %>
81
+ <%= form_with url: hr_lite.resolve_admin_ca_review_path(review, keep.()), method: :post, local: true, class: "hrl-row" do %>
82
+ <%= label_tag "resolution_#{review.id}", "How it was fixed", class: "hrl-sr-only" %>
83
+ <%= text_field_tag :note, nil, id: "resolution_#{review.id}", placeholder: "How it was fixed" %>
84
+ <%= submit_tag "Mark resolved", class: "hrl-btn" %>
85
+ <% end %>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+ </li>
90
+ <% end %>
91
+ </ul>
92
+ <%= hrl_pagination %>
93
+ <% end %>
94
+ </section>
@@ -0,0 +1,30 @@
1
+ <% content_for(:page_title) { "Fix leave" } %>
2
+ <a class="hrl-backlink" href="<%= hr_lite.admin_leave_request_path(@request) %>"><span aria-hidden="true">‹</span> Leave</a>
3
+ <div class="hrl-page__head">
4
+ <h1 class="hrl-page__title">Fix leave — <%= hr_display_name(@request.user) %></h1>
5
+ <p class="hrl-page__sub">Change the type, dates or day. The balance and loss of pay are worked out again, and they get an email.</p>
6
+ </div>
7
+
8
+ <section class="hrl-card">
9
+ <%= form_with model: @request, url: hr_lite.admin_leave_request_path(@request), method: :patch, local: true do |f| %>
10
+ <%= render "hr_lite/shared/form_errors", record: @request %>
11
+ <div class="hrl-field">
12
+ <%= f.label :leave_type_id, "Type" %>
13
+ <%= f.collection_select :leave_type_id, HrLite::LeaveType.active, :id, :name %>
14
+ </div>
15
+ <div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date %></div>
16
+ <div class="hrl-field"><%= f.label :end_date, "To" %><%= f.date_field :end_date %></div>
17
+ <div class="hrl-field">
18
+ <%= f.label :half_day_part, "Day" %>
19
+ <%= f.select :half_day_part, HrLite::DAY_PARTS %>
20
+ </div>
21
+ <div class="hrl-field">
22
+ <%= label_tag :correction_reason, "Reason for the fix (required)" %>
23
+ <%= text_area_tag :correction_reason, params[:correction_reason], required: true, class: "hrl-input" %>
24
+ </div>
25
+ <div class="hrl-form-actions">
26
+ <%= f.submit "Save fix", class: "hrl-btn hrl-btn--primary" %>
27
+ <a class="hrl-btn" href="<%= hr_lite.admin_leave_request_path(@request) %>">Back</a>
28
+ </div>
29
+ <% end %>
30
+ </section>
@@ -24,6 +24,16 @@
24
24
  </dl>
25
25
  </section>
26
26
 
27
+ <% if @request.approved? && @request.recorded_by_hr? %>
28
+ <section class="hrl-card">
29
+ <h2 class="hrl-card__title">Fix</h2>
30
+ <p class="hrl-small hrl-muted">Recorded by HR. Change it any time — no need to cancel.</p>
31
+ <div class="hrl-form-actions">
32
+ <a class="hrl-btn" href="<%= hr_lite.edit_admin_leave_request_path(@request) %>">Fix this leave</a>
33
+ </div>
34
+ </section>
35
+ <% end %>
36
+
27
37
  <%# An approved leave that has not started yet can still be called off — the
28
38
  balance is recomputed live, so cancelling gives the days straight back. %>
29
39
  <% if @request.cancellable_by?(hr_current_user) && !@request.pending? %>
data/config/routes.rb CHANGED
@@ -60,7 +60,7 @@ HrLite::Engine.routes.draw do
60
60
  resources :attendances, only: %i[index show update], param: :user_id do
61
61
  collection { get :week }
62
62
  end
63
- resources :leave_requests, only: %i[index show new create] do
63
+ resources :leave_requests, only: %i[index show new create edit update] do
64
64
  member { post :approve; post :reject; post :cancel }
65
65
  end
66
66
  resources :leave_balances, only: :index do
@@ -122,6 +122,10 @@ HrLite::Engine.routes.draw do
122
122
  end
123
123
  resources :performance_plans, only: %i[index new create edit update]
124
124
  resources :awards, only: %i[index new create edit update]
125
+ resources :ca_reviews, only: :index do
126
+ collection { post :verify; post :flag; post :verify_all }
127
+ member { post :resolve }
128
+ end
125
129
  resources :payroll_runs, only: %i[index show new create destroy] do
126
130
  member do
127
131
  post :compute
@@ -0,0 +1,21 @@
1
+ class CreateHrLiteCaReviews < ActiveRecord::Migration[8.1]
2
+ # The CA's year-end mark on one salary slip or tax declaration: verified,
3
+ # or flagged with what is wrong until HR/payroll resolves it.
4
+ def change
5
+ create_table :hr_lite_ca_reviews do |t|
6
+ t.string :subject_type, null: false
7
+ t.bigint :subject_id, null: false
8
+ # verified | flagged | resolved
9
+ t.string :status, null: false
10
+ t.text :note
11
+ t.bigint :reviewed_by_id, null: false
12
+ t.datetime :reviewed_at, null: false
13
+ t.bigint :resolved_by_id
14
+ t.datetime :resolved_at
15
+ t.text :resolution_note
16
+ t.timestamps
17
+ end
18
+ add_index :hr_lite_ca_reviews, %i[subject_type subject_id], unique: true
19
+ add_check_constraint :hr_lite_ca_reviews, "status IN ('verified', 'flagged', 'resolved')", name: "hr_lite_ca_reviews_status_check"
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ class SeedHrLiteCaRole < ActiveRecord::Migration[8.1]
2
+ # The CA role is new in 0.26.0 and payroll.verify is a new key. RoleSeeds
3
+ # creates the missing role; Super Admin, an existing role, gets the key here.
4
+ def up
5
+ return unless HrLite::Role.exists?
6
+
7
+ require "hr_lite/role_seeds"
8
+ HrLite::RoleSeeds.call
9
+ super_admin = HrLite::Role.find_by(name: "Super Admin") or return
10
+ HrLite::RoleGrant.find_or_initialize_by(role: super_admin, permission_key: "payroll.verify").update!(scope: "all")
11
+ end
12
+
13
+ def down; end
14
+ end
@@ -22,6 +22,9 @@ module HrLite
22
22
  "attendance.missed_check_out" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
23
23
  "attendance.short_day" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
24
24
  "payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
25
+ "ca.flagged" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
26
+ "ca.reminder" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
27
+ "ca.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
25
28
  "payroll.approval_needed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
26
29
  "payroll.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
27
30
  "payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
@@ -37,6 +37,7 @@ module HrLite
37
37
  "payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
38
38
  "payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
39
39
  "payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
40
+ "payroll.verify" => [ "Payroll", "Verify or flag each salary slip and tax declaration at year end (CA review)" ],
40
41
  "payroll.approve" => [ "Payroll", "Approve a computed payroll run (two approvals finalize and publish it)" ],
41
42
  "salary.view" => [ "Payroll", "See salary structures" ],
42
43
  "salary.manage" => [ "Payroll", "Set and revise salary structures" ],
@@ -70,6 +70,12 @@ module HrLite
70
70
  "asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
71
71
  }
72
72
  },
73
+ Role::CA => {
74
+ description: "Outside chartered accountant: reads every slip and tax declaration and verifies them before 31 March.",
75
+ grants: {
76
+ "payroll.view" => "all", "salary.view" => "all", "tax.view" => "all", "payroll.verify" => "all"
77
+ }
78
+ },
73
79
  Role::SUPER_ADMIN => {
74
80
  description: "Everything, including pay, appraisals and who holds which role.",
75
81
  grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.25.0"
2
+ VERSION = "0.27.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -114,7 +114,15 @@ module HrLite
114
114
  # Everyone HR tracks (host-overridable to exclude bots/test accounts),
115
115
  # sorted by display name for team screens.
116
116
  def employees
117
- config.employees_scope.call.sort_by { |u| display_name(u).downcase }
117
+ outside = outside_accountant_ids
118
+ config.employees_scope.call.reject { |u| outside.include?(u.id) }.sort_by { |u| display_name(u).downcase }
119
+ end
120
+
121
+ # CA logins with no HR profile: they review pay, they are not staff, so
122
+ # no attendance, leave, payroll or team list ever counts them.
123
+ def outside_accountant_ids
124
+ ids = HrLite::RoleAssignment.joins(:role).where(hr_lite_roles: { name: HrLite::Role::CA }).pluck(:user_id)
125
+ (ids - HrLite::EmployeeProfile.where(user_id: ids).pluck(:user_id)).to_set
118
126
  end
119
127
 
120
128
  # employees minus anyone whose profile says they have exited — user
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.25.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -80,6 +80,7 @@ files:
80
80
  - app/controllers/hr_lite/admin/awards_controller.rb
81
81
  - app/controllers/hr_lite/admin/base_controller.rb
82
82
  - app/controllers/hr_lite/admin/benefits_controller.rb
83
+ - app/controllers/hr_lite/admin/ca_reviews_controller.rb
83
84
  - app/controllers/hr_lite/admin/checklists_controller.rb
84
85
  - app/controllers/hr_lite/admin/comp_off_requests_controller.rb
85
86
  - app/controllers/hr_lite/admin/designation_changes_controller.rb
@@ -143,6 +144,8 @@ files:
143
144
  - app/jobs/hr_lite/application_job.rb
144
145
  - app/jobs/hr_lite/approval_escalation_job.rb
145
146
  - app/jobs/hr_lite/attendance_close_job.rb
147
+ - app/jobs/hr_lite/ca_review_overdue_job.rb
148
+ - app/jobs/hr_lite/ca_review_reminder_job.rb
146
149
  - app/jobs/hr_lite/daily_digest_job.rb
147
150
  - app/jobs/hr_lite/document_expiry_job.rb
148
151
  - app/jobs/hr_lite/leave_year_rollover_job.rb
@@ -167,6 +170,7 @@ files:
167
170
  - app/models/hr_lite/award.rb
168
171
  - app/models/hr_lite/benefit.rb
169
172
  - app/models/hr_lite/benefit_enrolment.rb
173
+ - app/models/hr_lite/ca_review.rb
170
174
  - app/models/hr_lite/checklist_item.rb
171
175
  - app/models/hr_lite/checklist_template.rb
172
176
  - app/models/hr_lite/comp_off_request.rb
@@ -237,6 +241,7 @@ files:
237
241
  - app/views/hr_lite/admin/awards/new.html.erb
238
242
  - app/views/hr_lite/admin/benefits/index.html.erb
239
243
  - app/views/hr_lite/admin/benefits/new.html.erb
244
+ - app/views/hr_lite/admin/ca_reviews/index.html.erb
240
245
  - app/views/hr_lite/admin/checklists/index.html.erb
241
246
  - app/views/hr_lite/admin/comp_off_requests/index.html.erb
242
247
  - app/views/hr_lite/admin/comp_off_requests/show.html.erb
@@ -257,6 +262,7 @@ files:
257
262
  - app/views/hr_lite/admin/hr_requests/index.html.erb
258
263
  - app/views/hr_lite/admin/hr_requests/show.html.erb
259
264
  - app/views/hr_lite/admin/leave_balances/index.html.erb
265
+ - app/views/hr_lite/admin/leave_requests/edit.html.erb
260
266
  - app/views/hr_lite/admin/leave_requests/index.html.erb
261
267
  - app/views/hr_lite/admin/leave_requests/new.html.erb
262
268
  - app/views/hr_lite/admin/leave_requests/show.html.erb
@@ -412,6 +418,8 @@ files:
412
418
  - db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb
413
419
  - db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb
414
420
  - db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb
421
+ - db/migrate/20261010110000_create_hr_lite_ca_reviews.rb
422
+ - db/migrate/20261010110100_seed_hr_lite_ca_role.rb
415
423
  - lib/generators/hr_lite/install/install_generator.rb
416
424
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
417
425
  - lib/generators/hr_lite/install/templates/initializer.rb