hr_lite 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +37 -1
- data/app/models/hr_lite/approval_flow.rb +1 -1
- data/app/models/hr_lite/benefit.rb +37 -0
- data/app/models/hr_lite/benefit_enrolment.rb +30 -0
- data/app/models/hr_lite/expense.rb +142 -0
- data/app/models/hr_lite/expense_category.rb +33 -0
- data/app/models/hr_lite/hr_request.rb +75 -0
- data/app/models/hr_lite/policy.rb +59 -0
- data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
- data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
- data/lib/hr_lite/notifications.rb +8 -0
- data/lib/hr_lite/permissions.rb +9 -0
- data/lib/hr_lite/role_seeds.rb +10 -3
- data/lib/hr_lite/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a035599be75b81c0d2f3d1fc1b8f95328df845445f6c93d997a9906f9b43846e
|
|
4
|
+
data.tar.gz: 8a840cc6c1af2ef49af660859fc6edf0f2ccec4b59377017c9cc0467793e8396
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bef92ea728cc960bc548c37fe3a8a34090ad531bec3ab248dc572ce9931f44fb5bf2591b1578df0c22f68ffd5ab7c02e6e7a8ed43445b1087524fc59584889b
|
|
7
|
+
data.tar.gz: 2598075e1f67e8e2c9e7fa4142db5e7561f42f17e32a9be6207ca560116a8e9d3ac7b7876c02e4bc77c250df6ef0ec0b05592ad9c073b22aaa8f01158129276f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
The four things an employee had to leave the system to do. **Adds one
|
|
13
|
+
migration.**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Expenses**, with categories carrying a monthly cap and a receipt rule.
|
|
18
|
+
The cap is checked when the claim is MADE, not at approval — telling
|
|
19
|
+
somebody they are over it after a week of waiting is the wrong moment to
|
|
20
|
+
find out. A claim awaiting a decision counts against the cap; a rejected
|
|
21
|
+
one gives the room back.
|
|
22
|
+
- Expenses route through the approval engine from 0.9.0 rather than growing a
|
|
23
|
+
fifth approve/reject. Configure a flow and a claim gets manager-then-finance
|
|
24
|
+
for free. `reimbursed` is a separate state from `approved`, because agreeing
|
|
25
|
+
to pay somebody and paying them are different days and the person waiting
|
|
26
|
+
cares about the second one.
|
|
27
|
+
- **Benefits and enrolments.** Dependants are a COUNT, not a list — names and
|
|
28
|
+
dates of birth of somebody's family are data this engine has no reason to
|
|
29
|
+
hold, and holding them would mean protecting them.
|
|
30
|
+
- **An HR help desk**: raise, assign, resolve, with the answer going back to
|
|
31
|
+
the person who asked instead of dying in somebody's inbox.
|
|
32
|
+
- **Policies, versioned.** Re-issuing asks everybody again, because an
|
|
33
|
+
acknowledgement of v1 says nothing about v2 — that is the whole point of
|
|
34
|
+
versioning it. Acknowledgements are `readonly?` once written: evidence that
|
|
35
|
+
can be edited afterwards is not evidence. Clicking twice is not an error.
|
|
36
|
+
- Permissions for all four, granted to the roles that should hold them, and
|
|
37
|
+
nine notification events.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `HrLite::Expense` is the second module on the approval engine, which is the
|
|
42
|
+
first real test of the claim made in 0.9.0 that a new module gets
|
|
43
|
+
multi-level approval without touching the engine. It did.
|
|
44
|
+
|
|
10
45
|
## [0.11.0] - 2026-08-18
|
|
11
46
|
|
|
12
47
|
Documents, and a tax declaration that is more than one number. **Adds one
|
|
@@ -694,7 +729,8 @@ Initial release.
|
|
|
694
729
|
bus with per-event channel matrix (bell, email, leadership email/bell),
|
|
695
730
|
daily leadership digest and an append-only audit trail.
|
|
696
731
|
|
|
697
|
-
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.
|
|
732
|
+
[Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...HEAD
|
|
733
|
+
[0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
|
|
698
734
|
[0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
|
|
699
735
|
[0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
|
|
700
736
|
[0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An insurance policy or other benefit the company provides. Employees can
|
|
3
|
+
# finally see what they are covered for without emailing somebody.
|
|
4
|
+
class Benefit < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
KINDS = %w[health life accident other].freeze
|
|
9
|
+
|
|
10
|
+
encrypted_money :coverage, :employer_premium, :employee_premium
|
|
11
|
+
|
|
12
|
+
has_many :benefit_enrolments, class_name: "HrLite::BenefitEnrolment", dependent: :destroy
|
|
13
|
+
has_many :enrolled_users, through: :benefit_enrolments, source: :user
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :kind, inclusion: { in: KINDS }
|
|
17
|
+
validate :expiry_is_after_the_start
|
|
18
|
+
|
|
19
|
+
scope :active, -> { where(active: true) }
|
|
20
|
+
scope :live_on, ->(date) {
|
|
21
|
+
active.where("effective_from IS NULL OR effective_from <= ?", date)
|
|
22
|
+
.where("expires_on IS NULL OR expires_on >= ?", date)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def expiring?(within: 30, on: Date.current)
|
|
26
|
+
expires_on.present? && expires_on <= on + within && expires_on >= on
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def expiry_is_after_the_start
|
|
32
|
+
return if effective_from.nil? || expires_on.nil? || expires_on >= effective_from
|
|
33
|
+
|
|
34
|
+
errors.add(:expires_on, "must be on or after the start date")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who is covered, from when. Dependants are a COUNT, not a list — names and
|
|
3
|
+
# dates of birth of somebody's family are data this engine has no reason to
|
|
4
|
+
# hold, and holding them would mean protecting them.
|
|
5
|
+
class BenefitEnrolment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :benefit, class_name: "HrLite::Benefit"
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
|
|
11
|
+
validates :enrolled_on, presence: true
|
|
12
|
+
validates :user_id, uniqueness: { scope: :benefit_id }
|
|
13
|
+
validates :dependants, numericality: { greater_than_or_equal_to: 0 }
|
|
14
|
+
validate :ended_after_it_started
|
|
15
|
+
|
|
16
|
+
scope :live_on, ->(date) {
|
|
17
|
+
where(enrolled_on: ..date).where("ended_on IS NULL OR ended_on >= ?", date)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def live?(on = Date.current) = enrolled_on <= on && (ended_on.nil? || ended_on >= on)
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def ended_after_it_started
|
|
25
|
+
return if ended_on.nil? || enrolled_on.nil? || ended_on >= enrolled_on
|
|
26
|
+
|
|
27
|
+
errors.add(:ended_on, "must be on or after the enrolment date")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A claim. Routed through the approval engine rather than growing a fifth
|
|
3
|
+
# approve/reject of its own — configure a flow for it and it gets manager
|
|
4
|
+
# then finance for free.
|
|
5
|
+
class Expense < ApplicationRecord
|
|
6
|
+
include EncryptedMoney
|
|
7
|
+
include Audited
|
|
8
|
+
include Approvable
|
|
9
|
+
|
|
10
|
+
STATUSES = %w[draft submitted approved rejected reimbursed cancelled].freeze
|
|
11
|
+
|
|
12
|
+
encrypted_money :amount
|
|
13
|
+
|
|
14
|
+
belongs_to :category, class_name: "HrLite::ExpenseCategory"
|
|
15
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
16
|
+
|
|
17
|
+
has_one_attached :receipt
|
|
18
|
+
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validates :description, presence: true
|
|
21
|
+
validates :spent_on, presence: true
|
|
22
|
+
validate :amount_is_positive
|
|
23
|
+
validate :spent_on_is_not_in_the_future
|
|
24
|
+
validate :within_the_category_cap, on: :create
|
|
25
|
+
validate :receipt_is_present_when_the_category_demands_one
|
|
26
|
+
|
|
27
|
+
scope :awaiting_reimbursement, -> { where(status: "approved") }
|
|
28
|
+
scope :recent_first, -> { order(spent_on: :desc, id: :desc) }
|
|
29
|
+
|
|
30
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
31
|
+
|
|
32
|
+
def submit!(actor:)
|
|
33
|
+
raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
|
|
34
|
+
|
|
35
|
+
update!(status: "submitted", submitted_at: Time.current)
|
|
36
|
+
notify_deciders
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def approve!(actor:, note: nil)
|
|
41
|
+
return record_routed!(actor, note, :approved) if awaiting?(actor)
|
|
42
|
+
|
|
43
|
+
settle!("approved", actor, note)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject!(actor:, note:)
|
|
47
|
+
raise ArgumentError, "a rejection needs a reason" if note.blank?
|
|
48
|
+
return record_routed!(actor, note, :rejected) if awaiting?(actor)
|
|
49
|
+
|
|
50
|
+
settle!("rejected", actor, note)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Paid out with a payroll month. Kept separate from `approved` because
|
|
54
|
+
# agreeing to pay somebody and actually paying them are different days,
|
|
55
|
+
# and the person waiting cares about the second one.
|
|
56
|
+
def reimburse!(actor:, period_month:)
|
|
57
|
+
raise ActiveRecord::RecordInvalid.new(self), "not approved" unless approved?
|
|
58
|
+
|
|
59
|
+
transaction do
|
|
60
|
+
update!(status: "reimbursed", reimbursed_in: period_month.beginning_of_month)
|
|
61
|
+
AuditLog.record!(action: "expense.reimbursed", subject: self, actor: actor,
|
|
62
|
+
changes: { "employee" => HrLite.display_name(user),
|
|
63
|
+
"period" => period_month.strftime("%B %Y") })
|
|
64
|
+
end
|
|
65
|
+
Notifications.publish(
|
|
66
|
+
"expense.reimbursed",
|
|
67
|
+
title: "Your #{category.name} claim was reimbursed with #{period_month.strftime('%B %Y')} payroll",
|
|
68
|
+
path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
69
|
+
)
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def record_routed!(actor, note, intent)
|
|
76
|
+
approval = approval_for(actor)
|
|
77
|
+
result = approval_route.decide!(approval, status: intent.to_s, actor: actor, note: note)
|
|
78
|
+
|
|
79
|
+
case result.outcome
|
|
80
|
+
when :approved then settle!("approved", actor, note)
|
|
81
|
+
# `reject!` already refuses a blank note, so there is nothing to
|
|
82
|
+
# default to here.
|
|
83
|
+
when :rejected then settle!("rejected", actor, note)
|
|
84
|
+
else
|
|
85
|
+
notify_deciders
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def settle!(new_status, actor, note)
|
|
91
|
+
update!(status: new_status, decision_note: note.presence)
|
|
92
|
+
Notifications.publish(
|
|
93
|
+
new_status == "approved" ? "expense.approved" : "expense.rejected",
|
|
94
|
+
title: "Your #{category.name} claim was #{new_status}",
|
|
95
|
+
body: note.presence, path: "/expenses", bell_to: [ user ], email_to: [ user ]
|
|
96
|
+
)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def notify_deciders
|
|
101
|
+
deciders = pending_approvals.map(&:approver).compact.uniq
|
|
102
|
+
deciders = HrLite.users_holding("expense.approve").to_a if deciders.empty?
|
|
103
|
+
return if deciders.empty?
|
|
104
|
+
|
|
105
|
+
Notifications.publish(
|
|
106
|
+
"expense.submitted",
|
|
107
|
+
title: "#{HrLite.display_name(user)} claimed #{Money.format(amount)} — #{category.name}",
|
|
108
|
+
body: description, path: "/admin/expenses", bell_to: deciders
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def amount_is_positive
|
|
113
|
+
errors.add(:amount, "must be more than zero") unless amount&.positive?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def spent_on_is_not_in_the_future
|
|
117
|
+
return if spent_on.nil? || spent_on <= Date.current
|
|
118
|
+
|
|
119
|
+
errors.add(:spent_on, "cannot be in the future")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checked at claim time rather than at approval: telling somebody they
|
|
123
|
+
# are over the cap after they have waited a week for an answer is the
|
|
124
|
+
# wrong moment to find out.
|
|
125
|
+
def within_the_category_cap
|
|
126
|
+
return if category.nil? || amount.nil? || spent_on.nil?
|
|
127
|
+
|
|
128
|
+
remaining = category.remaining_for(user, spent_on.beginning_of_month)
|
|
129
|
+
return if remaining.nil? || amount <= remaining
|
|
130
|
+
|
|
131
|
+
errors.add(:amount, "is over the #{category.name} cap for #{spent_on.strftime('%B')} " \
|
|
132
|
+
"— #{Money.format(remaining)} left")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def receipt_is_present_when_the_category_demands_one
|
|
136
|
+
return unless category&.receipt_required
|
|
137
|
+
return if receipt.attached?
|
|
138
|
+
|
|
139
|
+
errors.add(:receipt, "is required for #{category.name}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Travel", "Client entertainment", "Home office" — with the cap and the
|
|
3
|
+
# receipt rule that go with each.
|
|
4
|
+
class ExpenseCategory < ApplicationRecord
|
|
5
|
+
include EncryptedMoney
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
encrypted_money :monthly_cap
|
|
9
|
+
|
|
10
|
+
has_many :expenses, class_name: "HrLite::Expense", foreign_key: :category_id,
|
|
11
|
+
dependent: :restrict_with_error
|
|
12
|
+
|
|
13
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
14
|
+
|
|
15
|
+
scope :active, -> { where(active: true) }
|
|
16
|
+
scope :alphabetical, -> { order(:name) }
|
|
17
|
+
|
|
18
|
+
def uncapped? = monthly_cap.nil?
|
|
19
|
+
|
|
20
|
+
# What is left of this month's cap for one person, or nil when uncapped.
|
|
21
|
+
# Counts everything not refused: a claim awaiting a decision has still
|
|
22
|
+
# been spent against the cap.
|
|
23
|
+
def remaining_for(user, month = Date.current.beginning_of_month)
|
|
24
|
+
return nil if uncapped?
|
|
25
|
+
|
|
26
|
+
spent = expenses.where(user_id: user.id)
|
|
27
|
+
.where(spent_on: month..month.end_of_month)
|
|
28
|
+
.where.not(status: %w[rejected cancelled])
|
|
29
|
+
.sum(BigDecimal(0)) { |e| e.amount || BigDecimal(0) }
|
|
30
|
+
[ monthly_cap - spent, BigDecimal(0) ].max
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Can I have a salary certificate?" — the questions that were going to
|
|
3
|
+
# somebody's inbox and getting lost there.
|
|
4
|
+
class HrRequest < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[open in_progress resolved closed cancelled].freeze
|
|
8
|
+
|
|
9
|
+
CATEGORIES = %w[
|
|
10
|
+
salary_certificate employment_certificate address_change bank_change
|
|
11
|
+
document_request payroll_query tax_query insurance_query policy_query other
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
15
|
+
belongs_to :assigned_to, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :subject, presence: true
|
|
18
|
+
validates :category, inclusion: { in: CATEGORIES }
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
|
|
21
|
+
scope :open_requests, -> { where(status: %w[open in_progress]) }
|
|
22
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
23
|
+
|
|
24
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
25
|
+
|
|
26
|
+
after_create_commit :notify_desk
|
|
27
|
+
|
|
28
|
+
def category_label = category.humanize
|
|
29
|
+
|
|
30
|
+
def assign!(actor:, assignee:)
|
|
31
|
+
update!(assigned_to_id: assignee.id, status: "in_progress")
|
|
32
|
+
Notifications.publish(
|
|
33
|
+
"hr_request.assigned",
|
|
34
|
+
title: "#{category_label}: #{subject}",
|
|
35
|
+
body: "Assigned to you by #{HrLite.display_name(actor)}.",
|
|
36
|
+
path: "/admin/hr_requests/#{id}", bell_to: [ assignee ]
|
|
37
|
+
)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve!(actor:, resolution:)
|
|
42
|
+
raise ArgumentError, "a resolution needs an answer" if resolution.blank?
|
|
43
|
+
|
|
44
|
+
update!(status: "resolved", resolution: resolution, resolved_at: Time.current,
|
|
45
|
+
assigned_to_id: assigned_to_id || actor.id)
|
|
46
|
+
Notifications.publish(
|
|
47
|
+
"hr_request.resolved",
|
|
48
|
+
title: "Your request was answered — #{subject}",
|
|
49
|
+
body: resolution, path: "/hr_requests/#{id}",
|
|
50
|
+
bell_to: [ user ], email_to: [ user ]
|
|
51
|
+
)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cancel!(actor:)
|
|
56
|
+
raise ActiveRecord::RecordInvalid.new(self), "already settled" unless open? || in_progress?
|
|
57
|
+
|
|
58
|
+
update!(status: "cancelled")
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def notify_desk
|
|
65
|
+
desk = HrLite.users_holding("hr_request.manage").to_a
|
|
66
|
+
return if desk.empty?
|
|
67
|
+
|
|
68
|
+
Notifications.publish(
|
|
69
|
+
"hr_request.raised",
|
|
70
|
+
title: "#{HrLite.display_name(user)} asked: #{subject}",
|
|
71
|
+
body: body.presence, path: "/admin/hr_requests/#{id}", bell_to: desk
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A written policy people are held to. Versioned, because "you agreed to
|
|
3
|
+
# this" is only true of the version somebody actually read.
|
|
4
|
+
class Policy < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
has_many :policy_acknowledgements, class_name: "HrLite::PolicyAcknowledgement",
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :title, presence: true, uniqueness: { scope: :version }
|
|
11
|
+
validates :body, presence: true
|
|
12
|
+
validates :effective_from, presence: true
|
|
13
|
+
validates :version, numericality: { greater_than: 0 }
|
|
14
|
+
|
|
15
|
+
scope :published, -> { where(published: true) }
|
|
16
|
+
scope :live_on, ->(date) { published.where(effective_from: ..date) }
|
|
17
|
+
scope :newest_first, -> { order(effective_from: :desc, version: :desc) }
|
|
18
|
+
|
|
19
|
+
# The version in force today for each title — an older one stays for the
|
|
20
|
+
# acknowledgements attached to it, but nobody should be reading it.
|
|
21
|
+
def self.current
|
|
22
|
+
live_on(Date.current).group_by(&:title).values.map do |versions|
|
|
23
|
+
versions.max_by(&:version)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Re-issuing asks everybody again, which is the whole point of versioning
|
|
28
|
+
# it: an acknowledgement of v1 says nothing about v2.
|
|
29
|
+
def supersede!(body:, effective_from: Date.current, acknowledgement_required: nil)
|
|
30
|
+
self.class.create!(
|
|
31
|
+
title: title, body: body, version: version + 1, effective_from: effective_from,
|
|
32
|
+
acknowledgement_required: acknowledgement_required.nil? ? self.acknowledgement_required : acknowledgement_required,
|
|
33
|
+
published: true
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def acknowledged_by?(user)
|
|
38
|
+
policy_acknowledgements.exists?(user_id: user.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def outstanding_for
|
|
42
|
+
return HrLite.user_klass.none unless acknowledgement_required && published
|
|
43
|
+
|
|
44
|
+
HrLite.user_klass.where(id: HrLite.active_employees.map(&:id))
|
|
45
|
+
.where.not(id: policy_acknowledgements.select(:user_id))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Clicking twice is not an error and must not read as one. The
|
|
49
|
+
# find_or_create handles the second click; the rescue handles two
|
|
50
|
+
# requests arriving at once, which the unique index refuses.
|
|
51
|
+
def acknowledge!(user)
|
|
52
|
+
policy_acknowledgements.find_or_create_by!(user_id: user.id) do |ack|
|
|
53
|
+
ack.acknowledged_at = Time.current
|
|
54
|
+
end
|
|
55
|
+
rescue ActiveRecord::RecordNotUnique
|
|
56
|
+
policy_acknowledgements.find_by!(user_id: user.id)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I have read this." Kept per VERSION, and never edited — it is the
|
|
3
|
+
# evidence, and evidence that can be changed afterwards is not evidence.
|
|
4
|
+
class PolicyAcknowledgement < ApplicationRecord
|
|
5
|
+
belongs_to :policy, class_name: "HrLite::Policy"
|
|
6
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
7
|
+
|
|
8
|
+
validates :acknowledged_at, presence: true
|
|
9
|
+
validates :user_id, uniqueness: { scope: :policy_id }
|
|
10
|
+
|
|
11
|
+
def readonly? = persisted?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
class CreateHrLiteEmployeeServices < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Four things an employee has to leave the system to do today: claim an
|
|
3
|
+
# expense, see what insurance they have, ask HR a question, and read the
|
|
4
|
+
# policy they are being held to.
|
|
5
|
+
#
|
|
6
|
+
# Expenses and requests route through the approval engine from 0.9.0
|
|
7
|
+
# rather than growing a fifth and sixth approve/reject of their own.
|
|
8
|
+
def change
|
|
9
|
+
create_table :hr_lite_expense_categories do |t|
|
|
10
|
+
t.string :name, null: false
|
|
11
|
+
t.text :monthly_cap # encrypted; null = uncapped
|
|
12
|
+
t.boolean :receipt_required, null: false, default: true
|
|
13
|
+
t.boolean :active, null: false, default: true
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
add_index :hr_lite_expense_categories, :name, unique: true
|
|
17
|
+
|
|
18
|
+
create_table :hr_lite_expenses do |t|
|
|
19
|
+
t.bigint :user_id, null: false
|
|
20
|
+
t.references :category, null: false, index: true,
|
|
21
|
+
foreign_key: { to_table: :hr_lite_expense_categories }
|
|
22
|
+
t.text :amount, null: false # encrypted
|
|
23
|
+
t.date :spent_on, null: false
|
|
24
|
+
t.string :description, null: false
|
|
25
|
+
# draft | submitted | approved | rejected | reimbursed | cancelled
|
|
26
|
+
t.string :status, null: false, default: "draft"
|
|
27
|
+
t.datetime :submitted_at
|
|
28
|
+
t.string :decision_note
|
|
29
|
+
# Which payroll month paid it out, once it was reimbursed.
|
|
30
|
+
t.date :reimbursed_in
|
|
31
|
+
t.timestamps
|
|
32
|
+
end
|
|
33
|
+
add_index :hr_lite_expenses, %i[user_id status]
|
|
34
|
+
add_check_constraint :hr_lite_expenses,
|
|
35
|
+
"status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'cancelled')",
|
|
36
|
+
name: "hr_lite_expenses_status_check"
|
|
37
|
+
|
|
38
|
+
create_table :hr_lite_benefits do |t|
|
|
39
|
+
t.string :name, null: false
|
|
40
|
+
# health | life | accident | other
|
|
41
|
+
t.string :kind, null: false, default: "health"
|
|
42
|
+
t.string :provider
|
|
43
|
+
t.string :policy_number
|
|
44
|
+
t.text :coverage # encrypted — sum assured
|
|
45
|
+
t.text :employer_premium # encrypted
|
|
46
|
+
t.text :employee_premium # encrypted
|
|
47
|
+
t.date :effective_from
|
|
48
|
+
t.date :expires_on
|
|
49
|
+
t.text :notes
|
|
50
|
+
t.boolean :active, null: false, default: true
|
|
51
|
+
t.timestamps
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
create_table :hr_lite_benefit_enrolments do |t|
|
|
55
|
+
t.references :benefit, null: false, index: true,
|
|
56
|
+
foreign_key: { to_table: :hr_lite_benefits, on_delete: :cascade }
|
|
57
|
+
t.bigint :user_id, null: false
|
|
58
|
+
t.date :enrolled_on, null: false
|
|
59
|
+
t.date :ended_on
|
|
60
|
+
# Dependants covered, as a count — names and dates of birth are the
|
|
61
|
+
# kind of family data this engine has no reason to hold.
|
|
62
|
+
t.integer :dependants, null: false, default: 0
|
|
63
|
+
t.timestamps
|
|
64
|
+
end
|
|
65
|
+
add_index :hr_lite_benefit_enrolments, %i[benefit_id user_id], unique: true
|
|
66
|
+
|
|
67
|
+
create_table :hr_lite_hr_requests do |t|
|
|
68
|
+
t.bigint :user_id, null: false
|
|
69
|
+
# salary_certificate | address_change | bank_change | payroll_query | ...
|
|
70
|
+
t.string :category, null: false
|
|
71
|
+
t.string :subject, null: false
|
|
72
|
+
t.text :body
|
|
73
|
+
# open | in_progress | resolved | closed | cancelled
|
|
74
|
+
t.string :status, null: false, default: "open"
|
|
75
|
+
t.bigint :assigned_to_id
|
|
76
|
+
t.datetime :resolved_at
|
|
77
|
+
t.text :resolution
|
|
78
|
+
t.timestamps
|
|
79
|
+
end
|
|
80
|
+
add_index :hr_lite_hr_requests, %i[user_id status]
|
|
81
|
+
add_index :hr_lite_hr_requests, :assigned_to_id
|
|
82
|
+
add_check_constraint :hr_lite_hr_requests,
|
|
83
|
+
"status IN ('open', 'in_progress', 'resolved', 'closed', 'cancelled')",
|
|
84
|
+
name: "hr_lite_hr_requests_status_check"
|
|
85
|
+
|
|
86
|
+
create_table :hr_lite_policies do |t|
|
|
87
|
+
t.string :title, null: false
|
|
88
|
+
t.text :body, null: false
|
|
89
|
+
t.integer :version, null: false, default: 1
|
|
90
|
+
t.date :effective_from, null: false
|
|
91
|
+
t.boolean :acknowledgement_required, null: false, default: false
|
|
92
|
+
t.boolean :published, null: false, default: false
|
|
93
|
+
t.timestamps
|
|
94
|
+
end
|
|
95
|
+
add_index :hr_lite_policies, %i[title version], unique: true
|
|
96
|
+
|
|
97
|
+
create_table :hr_lite_policy_acknowledgements do |t|
|
|
98
|
+
t.references :policy, null: false, index: true,
|
|
99
|
+
foreign_key: { to_table: :hr_lite_policies, on_delete: :cascade }
|
|
100
|
+
t.bigint :user_id, null: false
|
|
101
|
+
t.datetime :acknowledged_at, null: false
|
|
102
|
+
t.timestamps
|
|
103
|
+
end
|
|
104
|
+
# One acknowledgement per person per VERSION: re-issuing a policy has to
|
|
105
|
+
# ask everybody again, which is the entire point of versioning it.
|
|
106
|
+
add_index :hr_lite_policy_acknowledgements, %i[policy_id user_id], unique: true
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -34,6 +34,14 @@ module HrLite
|
|
|
34
34
|
# A document about to lapse — a passport or a visa is somebody's right
|
|
35
35
|
# to work, and finding out late is the whole problem.
|
|
36
36
|
"document.expiring" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
37
|
+
"expense.submitted" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
38
|
+
"expense.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
39
|
+
"expense.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
40
|
+
"expense.reimbursed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
41
|
+
"hr_request.raised" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
42
|
+
"hr_request.assigned" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
|
|
43
|
+
"hr_request.resolved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
44
|
+
"policy.published" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
37
45
|
"digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false },
|
|
38
46
|
"resignation.submitted" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
|
|
39
47
|
"resignation.accepted" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
data/lib/hr_lite/permissions.rb
CHANGED
|
@@ -47,6 +47,15 @@ module HrLite
|
|
|
47
47
|
"document.manage" => [ "Documents", "Upload, verify and open identity and bank documents" ],
|
|
48
48
|
"tax.view" => [ "Payroll", "See tax declarations" ],
|
|
49
49
|
"tax.manage" => [ "Payroll", "Verify tax declarations and the proof behind them" ],
|
|
50
|
+
"expense.claim" => [ "Expenses", "Claim expenses" ],
|
|
51
|
+
"expense.approve" => [ "Expenses", "Approve and reject expense claims" ],
|
|
52
|
+
"expense.reimburse" => [ "Expenses", "Mark claims reimbursed with a payroll month" ],
|
|
53
|
+
"benefit.view" => [ "Benefits", "See benefits and who is enrolled" ],
|
|
54
|
+
"benefit.manage" => [ "Benefits", "Add benefits and enrol people" ],
|
|
55
|
+
"hr_request.raise" => [ "Help desk", "Raise a request with HR" ],
|
|
56
|
+
"hr_request.manage" => [ "Help desk", "Answer and assign HR requests" ],
|
|
57
|
+
"policy.view" => [ "Policies", "Read published policies" ],
|
|
58
|
+
"policy.manage" => [ "Policies", "Write, publish and re-issue policies" ],
|
|
50
59
|
"settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
|
|
51
60
|
"audit.view" => [ "Administration", "Read the audit trail" ],
|
|
52
61
|
"audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
|
data/lib/hr_lite/role_seeds.rb
CHANGED
|
@@ -17,7 +17,9 @@ module HrLite
|
|
|
17
17
|
"leave.request" => "self", "leave.view" => "self",
|
|
18
18
|
"attendance.view" => "self", "payroll.view" => "self",
|
|
19
19
|
"profile.view" => "self", "appraisal.view" => "self",
|
|
20
|
-
"resignation.view" => "self", "document.view" => "self", "tax.view" => "self"
|
|
20
|
+
"resignation.view" => "self", "document.view" => "self", "tax.view" => "self",
|
|
21
|
+
"expense.claim" => "self", "benefit.view" => "self",
|
|
22
|
+
"hr_request.raise" => "self", "policy.view" => "all"
|
|
21
23
|
}
|
|
22
24
|
},
|
|
23
25
|
Role::MANAGER => {
|
|
@@ -36,7 +38,9 @@ module HrLite
|
|
|
36
38
|
"leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
|
|
37
39
|
"profile.view" => "all", "payroll.view" => "self",
|
|
38
40
|
"appraisal.view" => "self", "resignation.view" => "all",
|
|
39
|
-
"document.view" => "all", "tax.view" => "self"
|
|
41
|
+
"document.view" => "all", "tax.view" => "self",
|
|
42
|
+
"expense.claim" => "self", "benefit.view" => "all", "benefit.manage" => "all",
|
|
43
|
+
"hr_request.raise" => "self", "hr_request.manage" => "all", "policy.view" => "all"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
Role::FINANCE => {
|
|
@@ -47,6 +51,8 @@ module HrLite
|
|
|
47
51
|
"payroll.view" => "all", "payroll.manage" => "all", "payroll.export" => "all",
|
|
48
52
|
"salary.view" => "all", "salary.manage" => "all",
|
|
49
53
|
"tax.view" => "all", "tax.manage" => "all",
|
|
54
|
+
"expense.claim" => "self", "expense.approve" => "all", "expense.reimburse" => "all",
|
|
55
|
+
"policy.view" => "all",
|
|
50
56
|
"audit.view" => "all", "audit.view_money" => "all"
|
|
51
57
|
}
|
|
52
58
|
},
|
|
@@ -58,7 +64,8 @@ module HrLite
|
|
|
58
64
|
"profile.view" => "all", "profile.manage" => "all",
|
|
59
65
|
"resignation.view" => "all", "resignation.manage" => "all",
|
|
60
66
|
"settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self",
|
|
61
|
-
"document.view" => "all"
|
|
67
|
+
"document.view" => "all", "expense.approve" => "all", "benefit.manage" => "all",
|
|
68
|
+
"hr_request.manage" => "all", "policy.view" => "all", "policy.manage" => "all"
|
|
62
69
|
}
|
|
63
70
|
},
|
|
64
71
|
Role::SUPER_ADMIN => {
|
data/lib/hr_lite/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -118,11 +118,16 @@ files:
|
|
|
118
118
|
- app/models/hr_lite/approval_step.rb
|
|
119
119
|
- app/models/hr_lite/attendance_record.rb
|
|
120
120
|
- app/models/hr_lite/audit_log.rb
|
|
121
|
+
- app/models/hr_lite/benefit.rb
|
|
122
|
+
- app/models/hr_lite/benefit_enrolment.rb
|
|
121
123
|
- app/models/hr_lite/comp_off_request.rb
|
|
122
124
|
- app/models/hr_lite/designation_change.rb
|
|
123
125
|
- app/models/hr_lite/document.rb
|
|
124
126
|
- app/models/hr_lite/employee_profile.rb
|
|
127
|
+
- app/models/hr_lite/expense.rb
|
|
128
|
+
- app/models/hr_lite/expense_category.rb
|
|
125
129
|
- app/models/hr_lite/holiday.rb
|
|
130
|
+
- app/models/hr_lite/hr_request.rb
|
|
126
131
|
- app/models/hr_lite/kudo.rb
|
|
127
132
|
- app/models/hr_lite/kudo_mention.rb
|
|
128
133
|
- app/models/hr_lite/leave_balance.rb
|
|
@@ -133,6 +138,8 @@ files:
|
|
|
133
138
|
- app/models/hr_lite/office_location.rb
|
|
134
139
|
- app/models/hr_lite/payroll_line_item.rb
|
|
135
140
|
- app/models/hr_lite/payroll_run.rb
|
|
141
|
+
- app/models/hr_lite/policy.rb
|
|
142
|
+
- app/models/hr_lite/policy_acknowledgement.rb
|
|
136
143
|
- app/models/hr_lite/professional_tax_slab.rb
|
|
137
144
|
- app/models/hr_lite/regularization_request.rb
|
|
138
145
|
- app/models/hr_lite/resignation.rb
|
|
@@ -283,6 +290,7 @@ files:
|
|
|
283
290
|
- db/migrate/20260818001151_create_hr_lite_approvals.rb
|
|
284
291
|
- db/migrate/20260818002627_create_hr_lite_payroll_components_and_loans.rb
|
|
285
292
|
- db/migrate/20260818003616_create_hr_lite_documents_and_declarations.rb
|
|
293
|
+
- db/migrate/20260818004707_create_hr_lite_employee_services.rb
|
|
286
294
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
287
295
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
288
296
|
- lib/generators/hr_lite/install/templates/initializer.rb
|