hr_lite 0.8.0 → 0.11.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/app/controllers/hr_lite/approvals_controller.rb +31 -0
  4. data/app/helpers/hr_lite/application_helper.rb +21 -0
  5. data/app/jobs/hr_lite/approval_escalation_job.rb +39 -0
  6. data/app/jobs/hr_lite/document_expiry_job.rb +28 -0
  7. data/app/models/concerns/hr_lite/approvable.rb +48 -0
  8. data/app/models/hr_lite/approval.rb +46 -0
  9. data/app/models/hr_lite/approval_delegation.rb +38 -0
  10. data/app/models/hr_lite/approval_flow.rb +39 -0
  11. data/app/models/hr_lite/approval_step.rb +62 -0
  12. data/app/models/hr_lite/document.rb +95 -0
  13. data/app/models/hr_lite/leave_request.rb +72 -17
  14. data/app/models/hr_lite/loan.rb +65 -0
  15. data/app/models/hr_lite/loan_repayment.rb +14 -0
  16. data/app/models/hr_lite/payroll_line_item.rb +57 -0
  17. data/app/models/hr_lite/payroll_run.rb +34 -0
  18. data/app/models/hr_lite/salary_component.rb +59 -0
  19. data/app/models/hr_lite/tax_declaration.rb +93 -0
  20. data/app/models/hr_lite/tax_declaration_item.rb +32 -0
  21. data/app/services/hr_lite/approval_route.rb +109 -0
  22. data/app/services/hr_lite/slip_builder.rb +76 -3
  23. data/app/views/hr_lite/approvals/index.html.erb +66 -0
  24. data/config/routes.rb +1 -0
  25. data/db/migrate/20260818001151_create_hr_lite_approvals.rb +79 -0
  26. data/db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb +72 -0
  27. data/db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb +73 -0
  28. data/lib/hr_lite/notifications.rb +10 -0
  29. data/lib/hr_lite/permissions.rb +4 -0
  30. data/lib/hr_lite/role_seeds.rb +6 -3
  31. data/lib/hr_lite/version.rb +1 -1
  32. data/lib/tasks/hr_lite_tasks.rake +2 -1
  33. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ddcf4d252c3b0830d1b345dd44f63dc997cab9db77a408249cd8940ad2256c1
4
- data.tar.gz: d525ae88d6a0b5c12ca68caff325bb655cd2ff7dfa0ac9afb7f178cbc8d362de
3
+ metadata.gz: 655b4d27557dace6262528eb82af6912b4b7dfcdc3ff6b8274068876b8bb970c
4
+ data.tar.gz: ba15eee422c61704681db9d8d1194851f8c761459524844ade6e4cd054776f81
5
5
  SHA512:
6
- metadata.gz: 155c04f716bbf5907b17ce1dd86c06f44de4f3f8021afda1e2ed3570db8cf14aed2373db7791d4c65441e96b6d4e0318faeb8dc1116b35bea235ed4cb31775d8
7
- data.tar.gz: be25c7f77087ae53dccb3e991167cc78282de562c5ef9f12739db9bc2bece99ad8b1e743577dc21d3aeb935cd1ca270ae64652bd036459c8c704b438db1ed8c0
6
+ metadata.gz: 4b4224f36f2cd4b3c256d440e6f439f59ce86e61436545f4f5a1afab97d827c64db86b4ee3b21f72003ea8864010b0369d6a719cd833a7194855adc1af10602c
7
+ data.tar.gz: 3de774d902610ab761c5794fe4f9eec3c404dac26ccf0fbea553bfe6bafe09e45c576e8264b07c70b6eef07af3f93f54c88aa43d3fd149921d4726456823a890
data/CHANGELOG.md CHANGED
@@ -7,6 +7,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-08-18
11
+
12
+ Documents, and a tax declaration that is more than one number. **Adds one
13
+ migration.** Requires Active Storage on the host (`rails active_storage:install`).
14
+
15
+ ### Added
16
+
17
+ - **`hr_lite_documents`** — there was nowhere to keep an employee's documents
18
+ at all. Files attach through Active Storage, which content-sniffs via
19
+ Marcel, so the type allowlist checks real bytes rather than what the client
20
+ claimed. SVG and HTML stay out: both carry script and both render in a
21
+ browser. 10 MB cap.
22
+ - **Visibility is a property of the row**, and an identity document defaults
23
+ to the tightest setting rather than the convenient one — an Aadhaar, PAN,
24
+ passport or bank mandate is money-tier by default, not HR's to browse. A
25
+ default nobody thinks about is the one that leaks. An explicit choice always
26
+ wins, which is why the column carries no default of its own.
27
+ - Verification with a named checker, and expiry with a job that warns at 30
28
+ days and again at 7 — not daily, because a warning that arrives every day
29
+ is one nobody reads by the third.
30
+ - **`hr_lite_tax_declarations`** and items, replacing
31
+ `declared_annual_deductions`: ONE opaque figure an admin typed in, that
32
+ nobody could see the makeup of, that the employee could not submit
33
+ themselves, and that had nowhere to keep the proof. It is the number the
34
+ whole year's TDS is projected from.
35
+ - Sections (80C, 80D, 80CCD(1B), 24(b), HRA), each with what was **declared**
36
+ and what the proof actually **supported**. Until HR has looked, the declared
37
+ figure stands — asking somebody to overpay tax all year because paperwork is
38
+ slow is its own kind of wrong. Once verified, only the supported figure
39
+ counts.
40
+ - The regime follows the declaration, because choosing one is a per-YEAR
41
+ decision and the profile column cannot express that.
42
+ - Permissions `document.view`, `document.manage`, `tax.view`, `tax.manage`,
43
+ granted to the roles that should hold them.
44
+
45
+ ### Changed
46
+
47
+ - `SlipBuilder` reads the declaration when one exists for the run's financial
48
+ year, falling back to the profile figure otherwise. A DRAFT declaration is
49
+ ignored — a draft is not a claim.
50
+
51
+ ## [0.10.0] - 2026-08-18
52
+
53
+ Payroll gets heads, arrears and loans. **Adds one migration.** Existing
54
+ payslips compute identically — every new head is opt-in data.
55
+
56
+ ### Added
57
+
58
+ - **`hr_lite_salary_components`** — earning and deduction heads as DATA.
59
+ Payroll knew exactly four earnings because they were columns on the salary
60
+ structure, so a bonus, an incentive, an LTA or a reimbursement had nowhere
61
+ to go but "other": one number on a payslip that should have said what it
62
+ was for. Seeded with bonus, incentive, arrears, LTA, reimbursement, loan
63
+ repayment and other-deduction; an install adds its own.
64
+ - Each head declares whether it is **prorated** (a bonus is not halved
65
+ because somebody joined on the 16th), **taxable**, and whether it
66
+ **counts toward the ESI gross** — a reimbursement is not a wage, and
67
+ counting it could push somebody over the ceiling and out of cover.
68
+ - **`hr_lite_payroll_line_items`** — one-offs for a single month, including
69
+ arrears after a backdated revision. Dated to the MONTH rather than the run,
70
+ so deleting a draft and computing it again does not lose them. Refused on a
71
+ month already published: that slip is immutable, and a later line would
72
+ show on no payslip while quietly moving the year-to-date the TDS projector
73
+ reads.
74
+ - **`hr_lite_loans`** and repayments. The outstanding balance is DERIVED from
75
+ the repayments actually taken, never stored — a stored balance and a
76
+ recomputed run disagree the first time somebody deletes a draft. The last
77
+ instalment takes only what is left.
78
+ - Repayments are booked when a run is **finalized**, not at compute: a draft
79
+ is recomputed as often as the operator likes, and each pass would otherwise
80
+ take another instalment. Unlocking a run gives them back, and reopens a
81
+ loan that had closed.
82
+
83
+ ### Changed
84
+
85
+ - Branch coverage is now a **floor at 90%, not a ratchet**, with the reasoning
86
+ written into `spec_helper.rb`. Its denominator grows with every subsystem,
87
+ so ratcheting it charges each feature PR a tax for branches in files it
88
+ never touched. Line coverage remains the ratchet at 100% overall and 90%
89
+ per file — that is the gate that has actually caught defects.
90
+ - Paid down some of the branch debt named in 0.9.0: the status predicates
91
+ across six models, the UAN format validation, and the holiday bulk-import
92
+ paths (blank lines, an empty paste, a line that cannot be read).
93
+
94
+ ## [0.9.0] - 2026-08-18
95
+
96
+ A reusable approval engine. **Adds one migration** (flows, steps, approvals,
97
+ delegations). Nothing changes for a module until a flow is configured for it,
98
+ which is what lets the four existing ones migrate one at a time.
99
+
100
+ ### Fixed
101
+
102
+ - **Cancelling a leave request left its approval sitting in somebody's
103
+ inbox.** Found by the coverage floor, which flagged `cancel_all!` as
104
+ unreachable — it was written and never called.
105
+
106
+ ### Added
107
+
108
+ - `HrLite::ApprovalFlow` / `ApprovalStep` / `Approval` / `ApprovalDelegation`.
109
+ A flow is "leave needs the manager, then HR". A step names its approver by
110
+ RULE — manager, manager's manager, anyone holding a permission, or one named
111
+ person — so a flow survives somebody leaving; the rule resolves against the
112
+ subject when the request is raised.
113
+ - Multi-level, sequential routing, with a `unanimous` step for the rungs where
114
+ everybody has to answer rather than the first person to look.
115
+ - **A rung nobody occupies is skipped**, not left waiting. An employee with no
116
+ manager recorded would otherwise have a request no living person could
117
+ decide.
118
+ - **Delegation.** "I am away until the 14th — Priya decides for me." The
119
+ approval does not move; a stand-in may answer it, and the row records both
120
+ who it was addressed to and who actually decided. Two hops, so a stand-in
121
+ who is also away is covered, and a loop cannot hand somebody their own
122
+ approvals back.
123
+ - **SLA and escalation.** A step may carry a deadline; `ApprovalEscalationJob`
124
+ tells an approver once per overdue row, grouped into one message per person,
125
+ and stamps the rows so a daily run does not nag daily. Escalation TELLS
126
+ somebody — it does not reassign, because a decision made by somebody who
127
+ never saw the request is worse than a late one.
128
+ - **One approval inbox** (`/approvals`) replacing four places to look. Employee
129
+ tier: holding an approval is the authorisation, so a manager, a stand-in and
130
+ a director reach the same screen and each sees only their own rows. It sits
131
+ after Calendar in the nav — the phone tab bar shows the first four, and this
132
+ screen is empty for anybody who is not an approver.
133
+ - `HrLite::Approvable` — opts a model in WITHOUT taking over what its decisions
134
+ mean. `LeaveRequest` keeps its own `approve!`, including the balance lock;
135
+ the concern only answers "is it this person's turn, and does their answer
136
+ settle it". A settling decision runs the ordinary transition, so routing
137
+ never becomes a second path that can drift from the first.
138
+
139
+ ### Changed
140
+
141
+ - `LeaveRequest` is the first module routed. With no flow it behaves exactly
142
+ as before, and somebody outside the current rung who holds `leave.approve`
143
+ at `all` can still settle it outright — the routed path is the normal one,
144
+ not the only one.
145
+ - Branch-coverage floor 90.8% -> 90.3%, deliberately, with the arithmetic
146
+ written into `spec_helper.rb`: the engine added ~90 branches at once, and
147
+ holding the percentage would have meant contriving specs for untouched
148
+ files. Line coverage stayed at 100%.
149
+
150
+ ### Not yet
151
+
152
+ - Comp-off, regularization and resignation still use their own single
153
+ decision. They are declared approvable and migrate next, one release each.
154
+
10
155
  ## [0.8.0] - 2026-08-18
11
156
 
12
157
  The deprecation 0.6.0 opened, closed. **Breaking** only for an install still
@@ -549,7 +694,10 @@ Initial release.
549
694
  bus with per-event channel matrix (bell, email, leadership email/bell),
550
695
  daily leadership digest and an append-only audit trail.
551
696
 
552
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...HEAD
697
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...HEAD
698
+ [0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
699
+ [0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
700
+ [0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
553
701
  [0.8.0]: https://github.com/kshtzkr/hr_lite/compare/v0.7.0...v0.8.0
554
702
  [0.7.0]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...v0.7.0
555
703
  [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,28 @@
1
+ module HrLite
2
+ # Tells people before a document lapses. A passport or a visa is somebody's
3
+ # right to work, and the whole problem with an expiry date is finding out
4
+ # about it late.
5
+ #
6
+ # Warns at 30 days and again at 7, rather than every day for a month.
7
+ class DocumentExpiryJob < ApplicationJob
8
+ WINDOWS = [ 30, 7 ].freeze
9
+
10
+ def perform(today: Date.current)
11
+ WINDOWS.each do |days|
12
+ Document.where(expires_on: today + days).includes(:user).find_each do |document|
13
+ Notifications.publish(
14
+ "document.expiring",
15
+ title: "#{document.title} expires in #{days} days",
16
+ body: "#{HrLite.display_name(document.user)} — #{document.category.humanize}, " \
17
+ "expiring #{document.expires_on.strftime('%d %b %Y')}.",
18
+ path: "/profile",
19
+ bell_to: [ document.user ],
20
+ email_to: [ document.user ],
21
+ leadership: { title: "#{HrLite.display_name(document.user)}'s #{document.title} " \
22
+ "expires in #{days} days" }
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ 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
@@ -0,0 +1,95 @@
1
+ module HrLite
2
+ # An employee's document: an Aadhaar scan, a PAN, an offer letter, a
3
+ # certificate. The FILE is the sensitive part, so visibility is a property
4
+ # of the row and every read goes through `readable_by?`.
5
+ class Document < ApplicationRecord
6
+ include Audited
7
+
8
+ VISIBILITIES = %w[self hr money].freeze
9
+ VERIFICATIONS = %w[pending verified rejected].freeze
10
+
11
+ # Categories the engine knows how to reason about. An install may store
12
+ # any string; these are the ones with an opinion attached.
13
+ SENSITIVE_CATEGORIES = %w[aadhaar pan passport bank].freeze
14
+
15
+ # Active Storage content-sniffs via Marcel, so this checks the real bytes
16
+ # rather than whatever the client claimed. SVG and HTML stay out — both
17
+ # can carry script and both render in a browser.
18
+ ALLOWED_TYPES = %w[
19
+ application/pdf image/jpeg image/png image/heic image/webp
20
+ ].freeze
21
+ MAX_BYTES = 10.megabytes
22
+
23
+ has_one_attached :file
24
+
25
+ belongs_to :user, class_name: HrLite.config.user_class
26
+ belongs_to :verified_by, class_name: HrLite.config.user_class, optional: true
27
+ belongs_to :uploaded_by, class_name: HrLite.config.user_class, optional: true
28
+
29
+ validates :category, :title, presence: true
30
+ validates :visibility, inclusion: { in: VISIBILITIES }
31
+ validates :verification, inclusion: { in: VERIFICATIONS }
32
+ validate :file_is_an_allowed_kind
33
+ validate :file_is_not_too_large
34
+ before_validation :default_visibility_from_category, on: :create
35
+
36
+ scope :for_user, ->(user) { where(user_id: user.id) }
37
+ scope :expiring_before, ->(date) { where.not(expires_on: nil).where(expires_on: ..date) }
38
+ scope :recent_first, -> { order(created_at: :desc) }
39
+
40
+ VERIFICATIONS.each { |v| define_method("#{v}?") { verification == v } }
41
+
42
+ def expired?(on = Date.current) = expires_on.present? && expires_on < on
43
+
44
+ # Who may open the FILE. The owner always may; beyond that it is the
45
+ # permission the row's visibility names. A passport is not a payslip and
46
+ # neither is HR's to browse by default.
47
+ def readable_by?(reader)
48
+ return false if reader.nil?
49
+ return true if reader.id == user_id
50
+
51
+ case visibility
52
+ when "self" then false
53
+ when "hr" then HrLite.reaches?(reader, "document.view", user)
54
+ when "money" then HrLite.can?(reader, "document.manage", scope: :all)
55
+ end
56
+ end
57
+
58
+ def verify!(actor:, note: nil)
59
+ update!(verification: "verified", verified_by_id: actor.id,
60
+ verified_at: Time.current, verification_note: note.presence)
61
+ end
62
+
63
+ def reject!(actor:, note:)
64
+ raise ArgumentError, "a rejection needs a reason" if note.blank?
65
+
66
+ update!(verification: "rejected", verified_by_id: actor.id,
67
+ verified_at: Time.current, verification_note: note)
68
+ end
69
+
70
+ private
71
+
72
+ # An Aadhaar or a bank mandate defaults to the tightest setting rather
73
+ # than the convenient one — a default nobody thinks about is the one that
74
+ # leaks. An explicit choice always wins, which is why the column carries
75
+ # no default of its own.
76
+ def default_visibility_from_category
77
+ return if visibility.present?
78
+
79
+ self.visibility = SENSITIVE_CATEGORIES.include?(category.to_s) ? "money" : "hr"
80
+ end
81
+
82
+ def file_is_an_allowed_kind
83
+ return unless file.attached?
84
+ return if ALLOWED_TYPES.include?(file.blob.content_type)
85
+
86
+ errors.add(:file, "must be a PDF or an image — #{file.blob.content_type} is not accepted")
87
+ end
88
+
89
+ def file_is_not_too_large
90
+ return unless file.attached? && file.blob.byte_size > MAX_BYTES
91
+
92
+ errors.add(:file, "must be under #{MAX_BYTES / 1.megabyte} MB")
93
+ end
94
+ end
95
+ end