hr_lite 0.8.0 → 0.9.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: 7ddcf4d252c3b0830d1b345dd44f63dc997cab9db77a408249cd8940ad2256c1
4
- data.tar.gz: d525ae88d6a0b5c12ca68caff325bb655cd2ff7dfa0ac9afb7f178cbc8d362de
3
+ metadata.gz: 42a084897bd2831303410129ecaa25dac81fdb97b711bfe07071cf28496d58d1
4
+ data.tar.gz: 014663d43940503d5fa840f5bb0460b8fd3e20bfbaf07a1aa6821b491798d16d
5
5
  SHA512:
6
- metadata.gz: 155c04f716bbf5907b17ce1dd86c06f44de4f3f8021afda1e2ed3570db8cf14aed2373db7791d4c65441e96b6d4e0318faeb8dc1116b35bea235ed4cb31775d8
7
- data.tar.gz: be25c7f77087ae53dccb3e991167cc78282de562c5ef9f12739db9bc2bece99ad8b1e743577dc21d3aeb935cd1ca270ae64652bd036459c8c704b438db1ed8c0
6
+ metadata.gz: 40177d861f9579695084b3e0bca3fefd4dd9593d430d800c7c1bfed2c88d1de4820fd12c6c816c24cf29f0d70eb8db26efe2c9cc10870ec1f9be15b262c05f1d
7
+ data.tar.gz: 261892abb65e4ebb80f12cdfbdf7763d2e82f9040268efa796870ec6af413cc25079e9e124536e2eb0a4e1a937e0de6a98f98fde84901248a91a3ea944f684bf
data/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-08-18
11
+
12
+ A reusable approval engine. **Adds one migration** (flows, steps, approvals,
13
+ delegations). Nothing changes for a module until a flow is configured for it,
14
+ which is what lets the four existing ones migrate one at a time.
15
+
16
+ ### Fixed
17
+
18
+ - **Cancelling a leave request left its approval sitting in somebody's
19
+ inbox.** Found by the coverage floor, which flagged `cancel_all!` as
20
+ unreachable — it was written and never called.
21
+
22
+ ### Added
23
+
24
+ - `HrLite::ApprovalFlow` / `ApprovalStep` / `Approval` / `ApprovalDelegation`.
25
+ A flow is "leave needs the manager, then HR". A step names its approver by
26
+ RULE — manager, manager's manager, anyone holding a permission, or one named
27
+ person — so a flow survives somebody leaving; the rule resolves against the
28
+ subject when the request is raised.
29
+ - Multi-level, sequential routing, with a `unanimous` step for the rungs where
30
+ everybody has to answer rather than the first person to look.
31
+ - **A rung nobody occupies is skipped**, not left waiting. An employee with no
32
+ manager recorded would otherwise have a request no living person could
33
+ decide.
34
+ - **Delegation.** "I am away until the 14th — Priya decides for me." The
35
+ approval does not move; a stand-in may answer it, and the row records both
36
+ who it was addressed to and who actually decided. Two hops, so a stand-in
37
+ who is also away is covered, and a loop cannot hand somebody their own
38
+ approvals back.
39
+ - **SLA and escalation.** A step may carry a deadline; `ApprovalEscalationJob`
40
+ tells an approver once per overdue row, grouped into one message per person,
41
+ and stamps the rows so a daily run does not nag daily. Escalation TELLS
42
+ somebody — it does not reassign, because a decision made by somebody who
43
+ never saw the request is worse than a late one.
44
+ - **One approval inbox** (`/approvals`) replacing four places to look. Employee
45
+ tier: holding an approval is the authorisation, so a manager, a stand-in and
46
+ a director reach the same screen and each sees only their own rows. It sits
47
+ after Calendar in the nav — the phone tab bar shows the first four, and this
48
+ screen is empty for anybody who is not an approver.
49
+ - `HrLite::Approvable` — opts a model in WITHOUT taking over what its decisions
50
+ mean. `LeaveRequest` keeps its own `approve!`, including the balance lock;
51
+ the concern only answers "is it this person's turn, and does their answer
52
+ settle it". A settling decision runs the ordinary transition, so routing
53
+ never becomes a second path that can drift from the first.
54
+
55
+ ### Changed
56
+
57
+ - `LeaveRequest` is the first module routed. With no flow it behaves exactly
58
+ as before, and somebody outside the current rung who holds `leave.approve`
59
+ at `all` can still settle it outright — the routed path is the normal one,
60
+ not the only one.
61
+ - Branch-coverage floor 90.8% -> 90.3%, deliberately, with the arithmetic
62
+ written into `spec_helper.rb`: the engine added ~90 branches at once, and
63
+ holding the percentage would have meant contriving specs for untouched
64
+ files. Line coverage stayed at 100%.
65
+
66
+ ### Not yet
67
+
68
+ - Comp-off, regularization and resignation still use their own single
69
+ decision. They are declared approvable and migrate next, one release each.
70
+
10
71
  ## [0.8.0] - 2026-08-18
11
72
 
12
73
  The deprecation 0.6.0 opened, closed. **Breaking** only for an install still
@@ -549,7 +610,8 @@ Initial release.
549
610
  bus with per-event channel matrix (bell, email, leadership email/bell),
550
611
  daily leadership digest and an append-only audit trail.
551
612
 
552
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...HEAD
613
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...HEAD
614
+ [0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
553
615
  [0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
554
616
  [0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
555
617
  [0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
@@ -0,0 +1,31 @@
1
+ module HrLite
2
+ # Everything waiting on the signed-in person, in one place. Before this,
3
+ # an approver had to visit four screens to find out whether anybody was
4
+ # waiting on them, and nothing told them if a request had gone stale.
5
+ #
6
+ # Employee tier on purpose: holding an approval IS the authorisation. A
7
+ # manager, a stand-in covering for one, and a director all reach the same
8
+ # screen and each sees only their own rows.
9
+ class ApprovalsController < ApplicationController
10
+ def index
11
+ @approvals = paginate(mine.includes(:step, :subject).order(:created_at))
12
+ @delegations = ApprovalDelegation.live_on(Date.current)
13
+ .where(from_user_id: hr_current_user.id)
14
+ .includes(:to_user)
15
+ @covering_for = ApprovalDelegation.live_on(Date.current)
16
+ .where(to_user_id: hr_current_user.id)
17
+ .includes(:from_user)
18
+ end
19
+
20
+ private
21
+
22
+ # Rows addressed to this person, plus rows belonging to anybody who has
23
+ # delegated to them while they are away.
24
+ def mine
25
+ standing_in_for = ApprovalDelegation.live_on(Date.current)
26
+ .where(to_user_id: hr_current_user.id)
27
+ .pluck(:from_user_id)
28
+ Approval.pending.where(approver_id: [ hr_current_user.id, *standing_in_for ])
29
+ end
30
+ end
31
+ end
@@ -6,6 +6,12 @@ module HrLite
6
6
  { label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ] },
7
7
  { label: "Team", path: :team_path, match: [ "/team" ] },
8
8
  { label: "Calendar", path: :calendar_path, match: [ "/calendar", "/holidays" ] },
9
+ # After Calendar deliberately: the phone tab bar shows the first four
10
+ # and this screen is empty for everybody who is not an approver, so it
11
+ # belongs in "More" rather than displacing something everybody uses.
12
+ # Employee tier all the same — holding an approval IS the authorisation,
13
+ # so a manager and a stand-in covering for one reach the same screen.
14
+ { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
9
15
  { label: "Org", path: :org_path, match: [ "/org" ] },
10
16
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
11
17
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
@@ -71,6 +77,21 @@ module HrLite
71
77
  out.html_safe
72
78
  end
73
79
 
80
+ # Where an approval row actually leads. Each subject type has its own
81
+ # decision screen; the inbox only gathers them.
82
+ APPROVAL_PATHS = {
83
+ "HrLite::LeaveRequest" => :admin_leave_request_path,
84
+ "HrLite::CompOffRequest" => :admin_comp_off_request_path,
85
+ "HrLite::RegularizationRequest" => :admin_regularization_request_path
86
+ }.freeze
87
+
88
+ def hrl_approval_path(approval)
89
+ helper = APPROVAL_PATHS[approval.subject_type]
90
+ return nil unless helper && hr_lite_route?(helper)
91
+
92
+ hr_lite.public_send(helper, approval.subject_id)
93
+ end
94
+
74
95
  def hrl_pagination
75
96
  render "hr_lite/shared/pagination"
76
97
  end
@@ -0,0 +1,39 @@
1
+ module HrLite
2
+ # Nudges approvals that have sat past their step's SLA. Escalation here
3
+ # means TELLING SOMEBODY, not reassigning: silently moving a decision to
4
+ # another person is how an approval ends up made by somebody who never saw
5
+ # the request.
6
+ #
7
+ # Each row is stamped once, so a daily run does not re-nag every day.
8
+ class ApprovalEscalationJob < ApplicationJob
9
+ def perform(now: Time.current)
10
+ overdue = Approval.pending.where(escalated_at: nil).includes(:step, :approver)
11
+ .select { |approval| approval.overdue?(now) }
12
+ return if overdue.empty?
13
+
14
+ overdue.group_by(&:approver_id).each do |approver_id, rows|
15
+ approver = rows.first.approver
16
+ next if approver.nil?
17
+
18
+ Notifications.publish(
19
+ "approval.escalated",
20
+ title: "#{rows.size} approval#{'s' unless rows.size == 1} waiting on you past its deadline",
21
+ lines: rows.map { |row| "#{row.label} — waiting #{waited(row, now)}" },
22
+ path: "/approvals",
23
+ bell_to: [ approver ],
24
+ email_to: [ approver ],
25
+ leadership: { title: "#{HrLite.display_name(approver)} has #{rows.size} overdue approval#{'s' unless rows.size == 1}" }
26
+ )
27
+ end
28
+
29
+ Approval.where(id: overdue.map(&:id)).update_all(escalated_at: now) # rubocop:disable Rails/SkipsModelValidations
30
+ end
31
+
32
+ private
33
+
34
+ def waited(approval, now)
35
+ hours = ((now - approval.created_at) / 1.hour).floor
36
+ hours < 48 ? "#{hours}h" : "#{(hours / 24)} days"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ module HrLite
2
+ # Opts a model into the routed, multi-step approval flow — WITHOUT taking
3
+ # over what its decisions mean.
4
+ #
5
+ # The model keeps its own `approve!` / `reject!`: those are the domain
6
+ # actions, and for leave one of them credits a balance inside a row lock.
7
+ # This concern only asks "is it this person's turn, and does their answer
8
+ # settle it?" and hands back an outcome for the model to act on.
9
+ #
10
+ # A subject type with no active flow behaves exactly as it did before —
11
+ # single decision, whoever holds the permission. That is what lets the four
12
+ # existing modules migrate one at a time instead of all at once.
13
+ module Approvable
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ has_many :approvals, as: :subject, class_name: "HrLite::Approval", dependent: :destroy
18
+ after_create_commit :open_approval_route
19
+ end
20
+
21
+ def approval_route = @approval_route ||= ApprovalRoute.new(self)
22
+
23
+ def routed_for_approval? = approval_route.routed?
24
+
25
+ # The rows still waiting, newest rung first — what the show screen lists.
26
+ def pending_approvals = approvals.pending.order(:position)
27
+
28
+ def approval_for(user)
29
+ return nil if user.nil?
30
+
31
+ pending_approvals.detect { |approval| approval.answerable_by?(user) }
32
+ end
33
+
34
+ # Whether this person is the one being waited on. Unrouted subjects fall
35
+ # back to the permission check the module already used.
36
+ def awaiting?(user) = approval_for(user).present?
37
+
38
+ # The whole story, for the approval screen: who decided what, in order,
39
+ # including the rungs that were skipped and why.
40
+ def approval_history = approvals.order(:position, :id)
41
+
42
+ private
43
+
44
+ def open_approval_route
45
+ approval_route.open! if approval_route.routed?
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ module HrLite
2
+ # One person's outstanding decision on one record. The row is the unit the
3
+ # inbox lists and the escalation job reads.
4
+ class Approval < ApplicationRecord
5
+ STATUSES = %w[pending approved rejected returned skipped cancelled].freeze
6
+
7
+ belongs_to :step, class_name: "HrLite::ApprovalStep"
8
+ belongs_to :subject, polymorphic: true
9
+ belongs_to :approver, class_name: HrLite.config.user_class
10
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :status, inclusion: { in: STATUSES }
13
+
14
+ scope :pending, -> { where(status: "pending") }
15
+ scope :at_position, ->(position) { where(position: position) }
16
+ scope :recent_first, -> { order(created_at: :desc) }
17
+
18
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
19
+
20
+ # Whether `user` may answer this — the approver themselves, or somebody
21
+ # they have delegated to while they are away.
22
+ def answerable_by?(user)
23
+ return false if user.nil? || !pending?
24
+
25
+ approver_id == user.id ||
26
+ ApprovalDelegation.stand_ins_for(approver_id).include?(user.id)
27
+ end
28
+
29
+ def decide!(status:, actor:, note: nil)
30
+ raise ArgumentError, "unknown decision #{status}" unless STATUSES.include?(status.to_s)
31
+
32
+ update!(status: status.to_s, note: note.presence, decided_at: Time.current,
33
+ decided_by_id: actor&.id)
34
+ end
35
+
36
+ # True once this row's SLA has run out. Computed rather than stored so
37
+ # editing a step's SLA takes effect on the rows already waiting.
38
+ def overdue?(now = Time.current)
39
+ return false unless pending? && step.sla_hours
40
+
41
+ created_at + step.sla_hours.hours <= now
42
+ end
43
+
44
+ def label = "#{subject_type.demodulize.underscore.humanize} ##{subject_id}"
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ module HrLite
2
+ # "I am away until the 14th — Priya decides for me." Delegation does not
3
+ # move the approval; it lets somebody else answer it, and the row records
4
+ # who actually did.
5
+ class ApprovalDelegation < ApplicationRecord
6
+ include Audited
7
+
8
+ belongs_to :from_user, class_name: HrLite.config.user_class
9
+ belongs_to :to_user, class_name: HrLite.config.user_class
10
+
11
+ validates :starts_on, :ends_on, presence: true
12
+ validate :ends_after_it_starts
13
+ validate :not_to_themselves
14
+
15
+ scope :live_on, ->(date) { where(starts_on: ..date).where(ends_on: date..) }
16
+
17
+ # Who may answer on `user`'s behalf today, including a chain of two —
18
+ # A delegates to B, B is also away and delegates to C. Capped at two
19
+ # hops: past that it is quicker to reassign the flow than to follow it.
20
+ def self.stand_ins_for(user_id, on: Date.current)
21
+ first = live_on(on).where(from_user_id: user_id).pluck(:to_user_id)
22
+ second = live_on(on).where(from_user_id: first).pluck(:to_user_id)
23
+ (first + second - [ user_id ]).uniq
24
+ end
25
+
26
+ private
27
+
28
+ def ends_after_it_starts
29
+ return unless starts_on && ends_on
30
+
31
+ errors.add(:ends_on, "must be on or after the start date") if ends_on < starts_on
32
+ end
33
+
34
+ def not_to_themselves
35
+ errors.add(:to_user_id, "cannot be the person delegating") if from_user_id == to_user_id
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ module HrLite
2
+ # "Leave needs the manager, then HR." One active flow per subject type;
3
+ # a type with no flow keeps whatever single-decision behaviour it had.
4
+ class ApprovalFlow < ApplicationRecord
5
+ include Audited
6
+
7
+ has_many :approval_steps, -> { order(:position) },
8
+ class_name: "HrLite::ApprovalStep", foreign_key: :flow_id, dependent: :destroy
9
+ accepts_nested_attributes_for :approval_steps, allow_destroy: true
10
+
11
+ validates :subject_type, :name, presence: true
12
+ validate :subject_type_is_approvable
13
+
14
+ scope :active, -> { where(active: true) }
15
+
16
+ def self.for(subject_type)
17
+ active.find_by(subject_type: subject_type.to_s)
18
+ end
19
+
20
+ # Subject types that have opted in. Declared rather than derived so a
21
+ # flow cannot be pointed at a model that has no idea it is approvable.
22
+ def self.approvable_types
23
+ %w[
24
+ HrLite::LeaveRequest HrLite::CompOffRequest
25
+ HrLite::RegularizationRequest HrLite::Resignation
26
+ ].freeze
27
+ end
28
+
29
+ def subject_label = subject_type.demodulize.underscore.humanize
30
+
31
+ private
32
+
33
+ def subject_type_is_approvable
34
+ return if self.class.approvable_types.include?(subject_type)
35
+
36
+ errors.add(:subject_type, "is not a type this engine can route for approval")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ module HrLite
2
+ # One rung. The approver is named by RULE, not by person, so a flow
3
+ # survives somebody leaving the company — the rule is resolved against the
4
+ # subject at the moment the request is raised.
5
+ class ApprovalStep < ApplicationRecord
6
+ RULES = %w[manager manager_of_manager permission user].freeze
7
+
8
+ belongs_to :flow, class_name: "HrLite::ApprovalFlow"
9
+
10
+ validates :position, presence: true, uniqueness: { scope: :flow_id }
11
+ validates :approver_rule, inclusion: { in: RULES }
12
+ validates :sla_hours, numericality: { greater_than: 0 }, allow_nil: true
13
+ validate :approver_key_matches_the_rule
14
+
15
+ scope :ordered, -> { order(:position) }
16
+
17
+ # Everybody who must (or may) decide this rung for `subject`. Empty means
18
+ # nobody fits — `ApprovalRoute` treats that as a rung to skip rather than
19
+ # a request nobody can ever answer.
20
+ # Array(...) rather than an `else` arm: `approver_rule` is held to the
21
+ # four RULES by a validation AND a database check constraint, so an
22
+ # unmatched `case` cannot happen — and a defensive branch nothing can
23
+ # reach is a line that never gets tested and quietly rots.
24
+ def approvers_for(subject)
25
+ Array(
26
+ case approver_rule
27
+ when "manager" then manager_of(subject.user_id)
28
+ when "manager_of_manager" then manager_of(manager_of(subject.user_id)&.id)
29
+ when "permission" then HrLite.users_holding(approver_key, scope: :all).to_a
30
+ when "user" then HrLite.user_klass.find_by(id: approver_key)
31
+ end
32
+ )
33
+ end
34
+
35
+ def label
36
+ case approver_rule
37
+ when "manager" then "Their manager"
38
+ when "manager_of_manager" then "Their manager's manager"
39
+ when "permission" then "Anyone who can #{Permissions.description(approver_key).downcase}"
40
+ when "user" then HrLite.display_name(HrLite.user_klass.find_by(id: approver_key))
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def manager_of(user_id)
47
+ return nil if user_id.nil?
48
+
49
+ manager_id = EmployeeProfile.where(user_id: user_id).pick(:manager_id)
50
+ manager_id && HrLite.user_klass.find_by(id: manager_id)
51
+ end
52
+
53
+ def approver_key_matches_the_rule
54
+ case approver_rule
55
+ when "permission"
56
+ errors.add(:approver_key, "must be a known permission") unless Permissions.valid?(approver_key.to_s)
57
+ when "user"
58
+ errors.add(:approver_key, "must name a user") if approver_key.blank?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,7 @@
1
1
  module HrLite
2
2
  class LeaveRequest < ApplicationRecord
3
+ include Approvable
4
+
3
5
  STATUSES = %w[pending approved rejected cancelled].freeze
4
6
 
5
7
  belongs_to :user, class_name: HrLite.config.user_class
@@ -38,30 +40,23 @@ module HrLite
38
40
 
39
41
  # --- transitions -------------------------------------------------------
40
42
 
43
+ # With a flow configured this is ONE RUNG: the request becomes `approved`
44
+ # only once the last rung is satisfied. With no flow — or from somebody
45
+ # the flow is not waiting on, such as HR overriding — the first approval
46
+ # settles it, exactly as before.
47
+ #
41
48
  # Returns false (leaving the request pending) when the balance no longer
42
- # covers it the re-check runs inside the row lock so two concurrent
49
+ # covers it; the re-check runs inside the row lock so two concurrent
43
50
  # approvals cannot overdraw one balance.
44
51
  def approve!(actor:, note: nil)
45
- insufficient = false
46
- transition!("approved", actor, note) do
47
- # Serialize on the BALANCE row. `transition!`'s own lock is on this
48
- # request, and two pending requests are two different rows — so both
49
- # approvals could read the same untouched balance and overdraw it.
50
- LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
51
-
52
- if insufficient_balance_now?
53
- insufficient = true
54
- raise ActiveRecord::Rollback
55
- end
56
- end
57
- return false if insufficient
52
+ return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
58
53
 
59
- notify_decision("Leave approved")
60
- notify_team
61
- true
54
+ approve_outright!(actor: actor, note: note)
62
55
  end
63
56
 
64
57
  def reject!(actor:, note:)
58
+ return record_routed_decision!(actor, note, :rejected) if awaiting?(actor)
59
+
65
60
  transition!("rejected", actor, note)
66
61
  notify_decision("Leave rejected")
67
62
  true
@@ -86,6 +81,9 @@ module HrLite
86
81
  # Cancelling an APPROVED leave hands the days back to the balance, so
87
82
  # it is a quota change as much as a status change.
88
83
  audit!("cancelled", actor, was_approved ? "was approved" : nil)
84
+ # And nobody should still be asked to decide a request that has been
85
+ # called off — it would sit in their inbox for ever.
86
+ approval_route.cancel_all!
89
87
  end
90
88
 
91
89
  Notifications.publish(
@@ -112,6 +110,63 @@ module HrLite
112
110
 
113
111
  private
114
112
 
113
+ # One rung answered. The route says whether that settles the request; if
114
+ # it does, the ordinary transition runs and does the real work — crediting
115
+ # or releasing balance, notifying, auditing — so routing never becomes a
116
+ # second path that can drift from the first.
117
+ def record_routed_decision!(actor, note, intent)
118
+ approval = approval_for(actor)
119
+ result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
120
+
121
+ case result.outcome
122
+ when :approved then approve_outright!(actor: actor, note: note)
123
+ when :rejected then reject_outright!(actor: actor, note: note.presence || "Rejected")
124
+ else
125
+ notify_pending_approvers
126
+ true
127
+ end
128
+ end
129
+
130
+ def approve_outright!(actor:, note:)
131
+ insufficient = false
132
+ transition!("approved", actor, note) do
133
+ # Serialize on the BALANCE row. `transition!`'s own lock is on this
134
+ # request, and two pending requests are two different rows — so both
135
+ # approvals could read the same untouched balance and overdraw it.
136
+ LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
137
+
138
+ if insufficient_balance_now?
139
+ insufficient = true
140
+ raise ActiveRecord::Rollback
141
+ end
142
+ end
143
+ return false if insufficient
144
+
145
+ notify_decision("Leave approved")
146
+ notify_team
147
+ true
148
+ end
149
+
150
+ def reject_outright!(actor:, note:)
151
+ transition!("rejected", actor, note)
152
+ notify_decision("Leave rejected")
153
+ true
154
+ end
155
+
156
+ # The next rung has just opened; tell the people it opened on.
157
+ def notify_pending_approvers
158
+ approvers = pending_approvals.map(&:approver).compact.uniq
159
+ return if approvers.empty?
160
+
161
+ Notifications.publish(
162
+ "leave.requested",
163
+ title: "#{HrLite.display_name(user)} — #{leave_type.name} (#{date_range_label}) needs your approval",
164
+ body: reason.presence,
165
+ path: "/admin/leave_requests/#{id}",
166
+ bell_to: approvers
167
+ )
168
+ end
169
+
115
170
  def transition!(new_status, actor, note)
116
171
  with_lock do
117
172
  raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
@@ -0,0 +1,109 @@
1
+ module HrLite
2
+ # Drives one record through its flow: opens the first rung, advances when a
3
+ # rung is satisfied, and reports when the whole thing is settled.
4
+ #
5
+ # It decides NOTHING about the subject itself. `Approvable` owns what
6
+ # "approved" means for a leave request; this class only knows whose turn it
7
+ # is. That split is why adding expenses later needs no change here.
8
+ class ApprovalRoute
9
+ Result = Struct.new(:outcome, :approval, keyword_init: true)
10
+
11
+ def initialize(subject)
12
+ @subject = subject
13
+ end
14
+
15
+ attr_reader :subject
16
+
17
+ def flow = @flow ||= ApprovalFlow.for(subject.class.name)
18
+
19
+ def routed? = flow.present?
20
+
21
+ # Opens the first rung that has anybody on it. A rung whose rule resolves
22
+ # to nobody — no manager recorded, say — is SKIPPED rather than left
23
+ # waiting for a person who does not exist.
24
+ def open!
25
+ return Result.new(outcome: :unrouted) unless routed?
26
+
27
+ flow.approval_steps.ordered.each do |step|
28
+ approvers = step.approvers_for(subject)
29
+ next skip!(step) if approvers.empty?
30
+
31
+ create_rows(step, approvers)
32
+ return Result.new(outcome: :pending)
33
+ end
34
+
35
+ # Every rung skipped: nobody in the flow can decide, so the request
36
+ # carries itself. Better than a request that can never be answered.
37
+ Result.new(outcome: :approved)
38
+ end
39
+
40
+ def pending_rows = Approval.pending.where(subject: subject)
41
+
42
+ def current_position = pending_rows.minimum(:position)
43
+
44
+ # Records one decision and works out what it means for the record.
45
+ #
46
+ # :pending — the rung still needs somebody
47
+ # :approved — every rung is satisfied
48
+ # :rejected — one refusal ends it
49
+ # :returned — sent back to the requester for correction
50
+ def decide!(approval, status:, actor:, note: nil)
51
+ approval.decide!(status: status, actor: actor, note: note)
52
+
53
+ case status.to_s
54
+ when "rejected", "returned"
55
+ cancel_remaining!(approval.position)
56
+ Result.new(outcome: status.to_sym, approval: approval)
57
+ else
58
+ advance_from(approval)
59
+ end
60
+ end
61
+
62
+ def cancel_all!
63
+ pending_rows.update_all(status: "cancelled", decided_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
64
+ end
65
+
66
+ private
67
+
68
+ def advance_from(approval)
69
+ rung = Approval.where(subject: subject).at_position(approval.position)
70
+ # A unanimous rung waits for everybody; otherwise the first answer
71
+ # settles it and the rest are stood down.
72
+ if approval.step.unanimous && rung.pending.exists?
73
+ return Result.new(outcome: :pending, approval: approval)
74
+ end
75
+
76
+ rung.pending.each { |other| other.decide!(status: "skipped", actor: nil) }
77
+ open_next_after(approval.position) || Result.new(outcome: :approved, approval: approval)
78
+ end
79
+
80
+ def open_next_after(position)
81
+ flow.approval_steps.ordered.each do |step|
82
+ next if step.position <= position
83
+
84
+ approvers = step.approvers_for(subject)
85
+ next skip!(step) if approvers.empty?
86
+
87
+ create_rows(step, approvers)
88
+ return Result.new(outcome: :pending)
89
+ end
90
+ nil
91
+ end
92
+
93
+ def create_rows(step, approvers)
94
+ approvers.uniq.each do |approver|
95
+ Approval.create!(subject: subject, step: step, position: step.position,
96
+ approver_id: approver.id)
97
+ end
98
+ end
99
+
100
+ # Nothing to record: a rung nobody occupies never happened. Returning nil
101
+ # keeps `each`'s `next` readable at the call sites.
102
+ def skip!(_step) = nil
103
+
104
+ def cancel_remaining!(position)
105
+ Approval.pending.where(subject: subject).where("position >= ?", position)
106
+ .each { |row| row.decide!(status: "cancelled", actor: nil) }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,66 @@
1
+ <% content_for(:page_title) { "Waiting on you" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Waiting on you</h1>
4
+ </div>
5
+
6
+ <% if @covering_for.any? %>
7
+ <section class="hrl-card">
8
+ <p class="hrl-small hrl-muted">
9
+ You are covering for
10
+ <%= @covering_for.map { |d| hr_display_name(d.from_user) }.to_sentence %>.
11
+ Their approvals appear below alongside your own.
12
+ </p>
13
+ </section>
14
+ <% end %>
15
+
16
+ <section class="hrl-card">
17
+ <% if @approvals.empty? %>
18
+ <p class="hrl-muted">Nothing is waiting on you.</p>
19
+ <% else %>
20
+ <div class="hrl-table-wrap">
21
+ <table class="hrl-table hrl-table--stack">
22
+ <thead><tr><th>Request</th><th>From</th><th>Waiting</th><th></th></tr></thead>
23
+ <tbody>
24
+ <% @approvals.each do |approval| %>
25
+ <% subject = approval.subject %>
26
+ <tr>
27
+ <td data-label="Request">
28
+ <strong><%= approval.label %></strong>
29
+ <% if approval.overdue? %>
30
+ <span class="hrl-badge hrl-badge--bad">Overdue</span>
31
+ <% end %>
32
+ <% if approval.approver_id != hr_current_user.id %>
33
+ <span class="hrl-badge hrl-badge--muted">
34
+ for <%= hr_display_name(approval.approver) %>
35
+ </span>
36
+ <% end %>
37
+ </td>
38
+ <td data-label="From"><%= hr_display_name(subject&.user) %></td>
39
+ <td data-label="Waiting"><%= time_ago_in_words(approval.created_at) %></td>
40
+ <td data-label="">
41
+ <% if (path = hrl_approval_path(approval)) %>
42
+ <a class="hrl-small" href="<%= path %>">Open</a>
43
+ <% end %>
44
+ </td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ <%= hrl_pagination %>
51
+ <% end %>
52
+ </section>
53
+
54
+ <% if @delegations.any? %>
55
+ <section class="hrl-card">
56
+ <h2 class="hrl-card__title">While you are away</h2>
57
+ <ul>
58
+ <% @delegations.each do |delegation| %>
59
+ <li class="hrl-small">
60
+ <%= hr_display_name(delegation.to_user) %> decides for you,
61
+ <%= delegation.starts_on.strftime("%d %b") %> – <%= delegation.ends_on.strftime("%d %b %Y") %>
62
+ </li>
63
+ <% end %>
64
+ </ul>
65
+ </section>
66
+ <% end %>
data/config/routes.rb CHANGED
@@ -19,6 +19,7 @@ HrLite::Engine.routes.draw do
19
19
  resources :regularization_requests, only: %i[index new create] do
20
20
  member { post :cancel }
21
21
  end
22
+ resources :approvals, only: :index
22
23
  get "team", to: "team#show"
23
24
  get "org", to: "org#show"
24
25
  resources :holidays, only: :index
@@ -0,0 +1,79 @@
1
+ class CreateHrLiteApprovals < ActiveRecord::Migration[8.1]
2
+ # Leave, comp-off, regularization and resignation each grew their own
3
+ # approve/reject/cancel, with their own transition guard and their own
4
+ # notification. Four copies of one idea, and every module still to come
5
+ # (expenses, loans, salary revisions, document verification) would have
6
+ # been a fifth.
7
+ #
8
+ # A FLOW is the definition — "leave needs the manager, then HR". A STEP is
9
+ # one rung of it, naming an approver by RULE rather than by person, so the
10
+ # flow survives somebody leaving. An APPROVAL is one live decision on one
11
+ # record.
12
+ def change
13
+ create_table :hr_lite_approval_flows do |t|
14
+ # The model this flow decides on, e.g. "HrLite::LeaveRequest".
15
+ t.string :subject_type, null: false
16
+ t.string :name, null: false
17
+ t.boolean :active, null: false, default: true
18
+ t.timestamps
19
+ end
20
+ add_index :hr_lite_approval_flows, :subject_type,
21
+ unique: true, where: "active", name: "index_hr_lite_approval_flows_one_active_per_type"
22
+
23
+ create_table :hr_lite_approval_steps do |t|
24
+ t.references :flow, null: false, index: false,
25
+ foreign_key: { to_table: :hr_lite_approval_flows, on_delete: :cascade }
26
+ t.integer :position, null: false
27
+ # manager | manager_of_manager | permission | user
28
+ t.string :approver_rule, null: false
29
+ # permission key for `permission`, user id for `user`, unused otherwise
30
+ t.string :approver_key
31
+ # Everybody on this rung must decide, rather than the first to answer.
32
+ t.boolean :unanimous, null: false, default: false
33
+ # Hours before the step is escalated. Null = never.
34
+ t.integer :sla_hours
35
+ t.timestamps
36
+ end
37
+ add_index :hr_lite_approval_steps, %i[flow_id position], unique: true
38
+ add_check_constraint :hr_lite_approval_steps,
39
+ "approver_rule IN ('manager', 'manager_of_manager', 'permission', 'user')",
40
+ name: "hr_lite_approval_steps_rule_check"
41
+
42
+ create_table :hr_lite_approvals do |t|
43
+ t.string :subject_type, null: false
44
+ t.bigint :subject_id, null: false
45
+ t.references :step, null: false, index: false,
46
+ foreign_key: { to_table: :hr_lite_approval_steps, on_delete: :cascade }
47
+ t.integer :position, null: false
48
+ t.bigint :approver_id, null: false
49
+ # pending | approved | rejected | returned | skipped | cancelled
50
+ t.string :status, null: false, default: "pending"
51
+ t.text :note
52
+ t.datetime :decided_at
53
+ # Who decided in the approver's place, when they had delegated.
54
+ t.bigint :decided_by_id
55
+ t.datetime :escalated_at
56
+ t.timestamps
57
+ end
58
+ add_index :hr_lite_approvals, %i[subject_type subject_id position]
59
+ # One live row per approver per rung: two would let the same person
60
+ # decide twice, or a retry double-count a unanimous step.
61
+ add_index :hr_lite_approvals, %i[subject_type subject_id position approver_id],
62
+ unique: true, name: "index_hr_lite_approvals_one_per_approver_per_step"
63
+ add_index :hr_lite_approvals, :approver_id
64
+ add_check_constraint :hr_lite_approvals,
65
+ "status IN ('pending', 'approved', 'rejected', 'returned', 'skipped', 'cancelled')",
66
+ name: "hr_lite_approvals_status_check"
67
+
68
+ create_table :hr_lite_approval_delegations do |t|
69
+ t.bigint :from_user_id, null: false
70
+ t.bigint :to_user_id, null: false
71
+ t.date :starts_on, null: false
72
+ t.date :ends_on, null: false
73
+ t.string :reason
74
+ t.timestamps
75
+ end
76
+ add_index :hr_lite_approval_delegations, %i[from_user_id starts_on ends_on],
77
+ name: "index_hr_lite_delegations_on_from_and_dates"
78
+ end
79
+ end
@@ -24,6 +24,9 @@ module HrLite
24
24
  "appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
25
25
  "promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
26
26
  "policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
27
+ # An approval past its deadline. Leadership hears too — a decision
28
+ # nobody is making is a process problem, not just one person's inbox.
29
+ "approval.escalated" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
27
30
  "digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
28
31
  "resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
29
32
  "resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -81,6 +81,7 @@ files:
81
81
  - app/controllers/hr_lite/admin/superadmin_controller.rb
82
82
  - app/controllers/hr_lite/application_controller.rb
83
83
  - app/controllers/hr_lite/appraisals_controller.rb
84
+ - app/controllers/hr_lite/approvals_controller.rb
84
85
  - app/controllers/hr_lite/attendance_controller.rb
85
86
  - app/controllers/hr_lite/calendar_controller.rb
86
87
  - app/controllers/hr_lite/career_controller.rb
@@ -99,15 +100,21 @@ files:
99
100
  - app/controllers/hr_lite/users_controller.rb
100
101
  - app/helpers/hr_lite/application_helper.rb
101
102
  - app/jobs/hr_lite/application_job.rb
103
+ - app/jobs/hr_lite/approval_escalation_job.rb
102
104
  - app/jobs/hr_lite/daily_digest_job.rb
103
105
  - app/jobs/hr_lite/leave_year_rollover_job.rb
104
106
  - app/jobs/hr_lite/payroll_auto_draft_job.rb
105
107
  - app/mailers/hr_lite/application_mailer.rb
106
108
  - app/mailers/hr_lite/event_mailer.rb
109
+ - app/models/concerns/hr_lite/approvable.rb
107
110
  - app/models/concerns/hr_lite/audited.rb
108
111
  - app/models/concerns/hr_lite/encrypted_money.rb
109
112
  - app/models/hr_lite/application_record.rb
110
113
  - app/models/hr_lite/appraisal.rb
114
+ - app/models/hr_lite/approval.rb
115
+ - app/models/hr_lite/approval_delegation.rb
116
+ - app/models/hr_lite/approval_flow.rb
117
+ - app/models/hr_lite/approval_step.rb
111
118
  - app/models/hr_lite/attendance_record.rb
112
119
  - app/models/hr_lite/audit_log.rb
113
120
  - app/models/hr_lite/comp_off_request.rb
@@ -132,6 +139,7 @@ files:
132
139
  - app/models/hr_lite/setting.rb
133
140
  - app/models/hr_lite/statutory_rate_card_record.rb
134
141
  - app/services/hr_lite/access.rb
142
+ - app/services/hr_lite/approval_route.rb
135
143
  - app/services/hr_lite/attendance_puncher.rb
136
144
  - app/services/hr_lite/attendance_summary.rb
137
145
  - app/services/hr_lite/calculators/esi.rb
@@ -195,6 +203,7 @@ files:
195
203
  - app/views/hr_lite/admin/statutory_rate_cards/new.html.erb
196
204
  - app/views/hr_lite/appraisals/index.html.erb
197
205
  - app/views/hr_lite/appraisals/show.html.erb
206
+ - app/views/hr_lite/approvals/index.html.erb
198
207
  - app/views/hr_lite/attendance/_month_grid.html.erb
199
208
  - app/views/hr_lite/attendance/_punch_card.html.erb
200
209
  - app/views/hr_lite/attendance/show.html.erb
@@ -263,6 +272,7 @@ files:
263
272
  - db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb
264
273
  - db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb
265
274
  - db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb
275
+ - db/migrate/20260818001151_create_hr_lite_approvals.rb
266
276
  - lib/generators/hr_lite/install/install_generator.rb
267
277
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
268
278
  - lib/generators/hr_lite/install/templates/initializer.rb