hr_lite 0.11.0 → 0.13.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/app/assets/stylesheets/hr_lite/hr_lite.css +6 -0
  4. data/app/controllers/hr_lite/admin/reports_controller.rb +151 -0
  5. data/app/controllers/hr_lite/benefits_controller.rb +17 -0
  6. data/app/controllers/hr_lite/expenses_controller.rb +50 -0
  7. data/app/controllers/hr_lite/hr_requests_controller.rb +43 -0
  8. data/app/controllers/hr_lite/policies_controller.rb +26 -0
  9. data/app/helpers/hr_lite/application_helper.rb +6 -1
  10. data/app/models/hr_lite/approval_flow.rb +1 -1
  11. data/app/models/hr_lite/benefit.rb +37 -0
  12. data/app/models/hr_lite/benefit_enrolment.rb +30 -0
  13. data/app/models/hr_lite/expense.rb +142 -0
  14. data/app/models/hr_lite/expense_category.rb +33 -0
  15. data/app/models/hr_lite/hr_request.rb +75 -0
  16. data/app/models/hr_lite/policy.rb +59 -0
  17. data/app/models/hr_lite/policy_acknowledgement.rb +13 -0
  18. data/app/views/hr_lite/admin/reports/index.html.erb +40 -0
  19. data/app/views/hr_lite/admin/reports/show.html.erb +32 -0
  20. data/app/views/hr_lite/benefits/index.html.erb +33 -0
  21. data/app/views/hr_lite/expenses/index.html.erb +68 -0
  22. data/app/views/hr_lite/expenses/new.html.erb +38 -0
  23. data/app/views/hr_lite/hr_requests/index.html.erb +39 -0
  24. data/app/views/hr_lite/hr_requests/new.html.erb +28 -0
  25. data/app/views/hr_lite/hr_requests/show.html.erb +25 -0
  26. data/app/views/hr_lite/policies/index.html.erb +34 -0
  27. data/app/views/hr_lite/policies/show.html.erb +23 -0
  28. data/config/routes.rb +11 -0
  29. data/db/migrate/20260818004707_create_hr_lite_employee_services.rb +108 -0
  30. data/lib/hr_lite/notifications.rb +8 -0
  31. data/lib/hr_lite/permissions.rb +9 -0
  32. data/lib/hr_lite/role_seeds.rb +10 -3
  33. data/lib/hr_lite/version.rb +1 -1
  34. metadata +24 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 655b4d27557dace6262528eb82af6912b4b7dfcdc3ff6b8274068876b8bb970c
4
- data.tar.gz: ba15eee422c61704681db9d8d1194851f8c761459524844ade6e4cd054776f81
3
+ metadata.gz: 8202157b6f35309884c9df17895d711553e114a57925c8c2e381e51a7be863ba
4
+ data.tar.gz: 3cb55e413c6bcc70bd3aa39a5c212690a21c9cf747b17bfdf5894ef6d73945de
5
5
  SHA512:
6
- metadata.gz: 4b4224f36f2cd4b3c256d440e6f439f59ce86e61436545f4f5a1afab97d827c64db86b4ee3b21f72003ea8864010b0369d6a719cd833a7194855adc1af10602c
7
- data.tar.gz: 3de774d902610ab761c5794fe4f9eec3c404dac26ccf0fbea553bfe6bafe09e45c576e8264b07c70b6eef07af3f93f54c88aa43d3fd149921d4726456823a890
6
+ metadata.gz: 0ddf504c20a84c58a712fd4efe695e9b90b27eea8acc8883bfb37d33df398778169c35eb2c11f37830e91c9ceec63560df57c631246b2b22381b52df5ce30144
7
+ data.tar.gz: 4a724d4a55068129237e010aca71ce94c392656bb56953a6f1ec5329ddf5d33b6ffd91f76c82229a152f85d134a60ca29308634133f0a3a72779cff3cc6dafdb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-08-18
11
+
12
+ The screens. No migration.
13
+
14
+ ### Added
15
+
16
+ - **Employee self-service for everything built since 0.11.0**: claim an
17
+ expense (with what is left of each cap shown before you type an amount),
18
+ see what you are covered for, read and acknowledge a policy, ask HR a
19
+ question and read the answer.
20
+ - **A reports hub** — headcount, joiners and leavers, leave balances,
21
+ attendance and expenses — each with a CSV download, and each **scoped by
22
+ the same permission that guards the screen its data comes from**. A report
23
+ is not a side door into rows somebody cannot otherwise reach: a manager's
24
+ headcount is their own team. The hub lists only the reports the reader can
25
+ actually open, since a list of links that turn you away is worse than a
26
+ short list.
27
+ - CSV downloads are audited, the same way the payout register is.
28
+
29
+ ### Fixed
30
+
31
+ - **The reports hub locked Finance out of the expense report.** It inherited
32
+ the operations gate, and Finance approves expenses but runs none of the
33
+ operations screens. Found by writing the spec for it.
34
+
35
+ ### Changed
36
+
37
+ - `.hrl-prose` for long-form policy text, capped at 68 characters a line — a
38
+ policy read across the full width of a laptop is a policy nobody finishes.
39
+ - The demo sandbox seeds an expense claim, a benefit enrolment, a policy
40
+ awaiting acknowledgement and an open HR request, so the new screens have
41
+ something on them at first boot.
42
+
43
+ ## [0.12.0] - 2026-08-18
44
+
45
+ The four things an employee had to leave the system to do. **Adds one
46
+ migration.**
47
+
48
+ ### Added
49
+
50
+ - **Expenses**, with categories carrying a monthly cap and a receipt rule.
51
+ The cap is checked when the claim is MADE, not at approval — telling
52
+ somebody they are over it after a week of waiting is the wrong moment to
53
+ find out. A claim awaiting a decision counts against the cap; a rejected
54
+ one gives the room back.
55
+ - Expenses route through the approval engine from 0.9.0 rather than growing a
56
+ fifth approve/reject. Configure a flow and a claim gets manager-then-finance
57
+ for free. `reimbursed` is a separate state from `approved`, because agreeing
58
+ to pay somebody and paying them are different days and the person waiting
59
+ cares about the second one.
60
+ - **Benefits and enrolments.** Dependants are a COUNT, not a list — names and
61
+ dates of birth of somebody's family are data this engine has no reason to
62
+ hold, and holding them would mean protecting them.
63
+ - **An HR help desk**: raise, assign, resolve, with the answer going back to
64
+ the person who asked instead of dying in somebody's inbox.
65
+ - **Policies, versioned.** Re-issuing asks everybody again, because an
66
+ acknowledgement of v1 says nothing about v2 — that is the whole point of
67
+ versioning it. Acknowledgements are `readonly?` once written: evidence that
68
+ can be edited afterwards is not evidence. Clicking twice is not an error.
69
+ - Permissions for all four, granted to the roles that should hold them, and
70
+ nine notification events.
71
+
72
+ ### Changed
73
+
74
+ - `HrLite::Expense` is the second module on the approval engine, which is the
75
+ first real test of the claim made in 0.9.0 that a new module gets
76
+ multi-level approval without touching the engine. It did.
77
+
10
78
  ## [0.11.0] - 2026-08-18
11
79
 
12
80
  Documents, and a tax declaration that is more than one number. **Adds one
@@ -694,7 +762,9 @@ Initial release.
694
762
  bus with per-event channel matrix (bell, email, leadership email/bell),
695
763
  daily leadership digest and an append-only audit trail.
696
764
 
697
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...HEAD
765
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...HEAD
766
+ [0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
767
+ [0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
698
768
  [0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
699
769
  [0.10.0]: https://github.com/kshtzkr/hr_lite/compare/v0.9.0...v0.10.0
700
770
  [0.9.0]: https://github.com/kshtzkr/hr_lite/compare/v0.8.0...v0.9.0
@@ -318,3 +318,9 @@
318
318
  .hrl-mt { margin-top: .85rem; }
319
319
  .hrl-right { margin-left: auto; }
320
320
  .hrl-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
321
+
322
+ /* Long-form policy text. Constrained measure — a policy read across the
323
+ full width of a laptop is a policy nobody finishes. */
324
+ .hrl-prose { max-width: 68ch; }
325
+ .hrl-prose p { margin: 0 0 .8rem; line-height: 1.6; }
326
+ .hrl-prose p:last-child { margin-bottom: 0; }
@@ -0,0 +1,151 @@
1
+ module HrLite
2
+ module Admin
3
+ # The numbers somebody asks for at the end of a month, and the CSV they
4
+ # ask for straight afterwards.
5
+ #
6
+ # Every report is scoped by the SAME permission that guards the screens
7
+ # its data comes from — a report is not a side door into rows somebody
8
+ # cannot otherwise reach.
9
+ class ReportsController < BaseController
10
+ # Finance can approve expenses but runs none of the operations screens,
11
+ # so the operations gate would have shut them out of their own expense
12
+ # report. The hub is reachable by anybody who can see AT LEAST ONE
13
+ # report; each report then checks its own permission.
14
+ skip_before_action :require_operations_access!
15
+ before_action :require_any_report!
16
+
17
+ REPORTS = {
18
+ "headcount" => "Headcount by department",
19
+ "joiners_and_leavers" => "Joiners and leavers",
20
+ "leave_balances" => "Leave balances",
21
+ "attendance" => "Attendance summary",
22
+ "expenses" => "Expense claims"
23
+ }.freeze
24
+
25
+ def index
26
+ # Only the reports this person could actually open — a list of links
27
+ # that turn you away is worse than a shorter list.
28
+ @reports = REPORTS.select { |name, _| permitted?(name) }
29
+ @month = parse_month_param(params[:month])
30
+ end
31
+
32
+ def show
33
+ @month = parse_month_param(params[:month])
34
+ @name = params[:id]
35
+ return hr_access_denied unless REPORTS.key?(@name)
36
+ return hr_access_denied unless permitted?(@name)
37
+
38
+ @rows = rows_for(@name)
39
+ respond_to do |format|
40
+ format.html
41
+ format.csv do
42
+ AuditLog.record!(action: "report.exported", subject: hr_current_user,
43
+ actor: hr_current_user,
44
+ changes: { "report" => @name, "rows" => @rows.size,
45
+ "month" => @month.strftime("%Y-%m") })
46
+ send_data to_csv(@rows), filename: "#{@name}-#{@month.strftime('%Y-%m')}.csv",
47
+ type: "text/csv"
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def require_any_report!
55
+ hr_access_denied if REPORTS.keys.none? { |name| permitted?(name) }
56
+ end
57
+
58
+ # A report reaches exactly as far as the screen it summarises.
59
+ def permitted?(name)
60
+ case name
61
+ when "expenses" then hr_can?("expense.approve", scope: :all)
62
+ when "leave_balances" then hr_can?("leave.view", scope: :team)
63
+ when "attendance" then hr_can?("attendance.view", scope: :team)
64
+ else hr_can?("profile.view", scope: :team)
65
+ end
66
+ end
67
+
68
+ def visible_users
69
+ ids = hr_access.visible_user_ids("profile.view")
70
+ employees = HrLite.employees
71
+ ids.nil? ? employees : employees.select { |user| ids.include?(user.id) }
72
+ end
73
+
74
+ def rows_for(name)
75
+ case name
76
+ when "headcount" then headcount_rows
77
+ when "joiners_and_leavers" then joiners_and_leavers_rows
78
+ when "leave_balances" then leave_balance_rows
79
+ when "attendance" then attendance_rows
80
+ when "expenses" then expense_rows
81
+ end
82
+ end
83
+
84
+ def headcount_rows
85
+ profiles = EmployeeProfile.where(user_id: visible_users.map(&:id))
86
+ .active_for(@month)
87
+ profiles.group_by { |p| p.department.presence || "Unassigned" }
88
+ .sort_by(&:first)
89
+ .map { |department, rows| { "Department" => department, "People" => rows.size } }
90
+ end
91
+
92
+ def joiners_and_leavers_rows
93
+ window = @month..@month.end_of_month
94
+ EmployeeProfile.where(user_id: visible_users.map(&:id))
95
+ .where("date_of_joining BETWEEN ? AND ? OR date_of_exit BETWEEN ? AND ?",
96
+ window.first, window.last, window.first, window.last)
97
+ .includes(:user)
98
+ .map do |profile|
99
+ { "Employee" => HrLite.display_name(profile.user),
100
+ "Department" => profile.department,
101
+ "Joined" => profile.date_of_joining&.to_s,
102
+ "Left" => profile.date_of_exit&.to_s }
103
+ end
104
+ end
105
+
106
+ def leave_balance_rows
107
+ year = LeaveYear.key_for(@month)
108
+ types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
109
+ visible_users.flat_map do |user|
110
+ types.map do |type|
111
+ balance = LeaveBalance.for(user, type, year)
112
+ { "Employee" => HrLite.display_name(user), "Type" => type.code,
113
+ "Entitled" => balance.entitled.to_f, "Used" => balance.used.to_f,
114
+ "Available" => balance.available.to_f }
115
+ end
116
+ end
117
+ end
118
+
119
+ def attendance_rows
120
+ visible_users.map do |user|
121
+ summary = AttendanceSummary.for(user: user, month: @month)
122
+ { "Employee" => HrLite.display_name(user),
123
+ "Payable days" => summary[:payable_days].to_f,
124
+ "LOP" => summary[:lop_days].to_f,
125
+ "Days in month" => summary[:days_in_month] }
126
+ end
127
+ end
128
+
129
+ def expense_rows
130
+ Expense.where(spent_on: @month..@month.end_of_month)
131
+ .includes(:user, :category).recent_first.map do |expense|
132
+ { "Employee" => HrLite.display_name(expense.user),
133
+ "Category" => expense.category.name,
134
+ "Spent on" => expense.spent_on.to_s,
135
+ "Amount" => expense.amount.to_s("F"),
136
+ "Status" => expense.status }
137
+ end
138
+ end
139
+
140
+ def to_csv(rows)
141
+ require "csv"
142
+ return "" if rows.empty?
143
+
144
+ CSV.generate do |csv|
145
+ csv << rows.first.keys
146
+ rows.each { |row| csv << row.values }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,17 @@
1
+ module HrLite
2
+ # What the signed-in person is actually covered for — previously something
3
+ # you had to email somebody to find out.
4
+ class BenefitsController < ApplicationController
5
+ before_action :require_viewing!
6
+
7
+ def index
8
+ @enrolments = BenefitEnrolment.live_on(Date.current)
9
+ .where(user_id: hr_current_user.id)
10
+ .includes(:benefit)
11
+ end
12
+
13
+ private
14
+
15
+ def require_viewing! = hr_require_permission!("benefit.view")
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ module HrLite
2
+ # An employee's own claims. Scoped to the signed-in person the same way
3
+ # every other self-service screen is: a foreign id 404s through the
4
+ # relation rather than 403ing.
5
+ class ExpensesController < ApplicationController
6
+ before_action :require_claiming!
7
+
8
+ def index
9
+ @expenses = paginate(own.includes(:category).recent_first)
10
+ @categories = ExpenseCategory.active.alphabetical
11
+ end
12
+
13
+ def new
14
+ @expense = Expense.new(spent_on: Date.current)
15
+ @categories = ExpenseCategory.active.alphabetical
16
+ end
17
+
18
+ def create
19
+ @expense = Expense.new(expense_params.merge(user_id: hr_current_user.id))
20
+ if @expense.save
21
+ @expense.submit!(actor: hr_current_user)
22
+ redirect_to expenses_path, notice: "Claim submitted."
23
+ else
24
+ @categories = ExpenseCategory.active.alphabetical
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def cancel
30
+ expense = own.find(params[:id])
31
+ if expense.draft? || expense.submitted?
32
+ expense.update!(status: "cancelled")
33
+ expense.approval_route.cancel_all!
34
+ redirect_to expenses_path, notice: "Claim withdrawn."
35
+ else
36
+ redirect_to expenses_path, alert: "Only a claim still waiting can be withdrawn."
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def require_claiming! = hr_require_permission!("expense.claim")
43
+
44
+ def own = Expense.where(user_id: hr_current_user.id)
45
+
46
+ def expense_params
47
+ params.require(:expense).permit(:category_id, :amount, :spent_on, :description, :receipt)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ # The employee's side of the help desk.
3
+ class HrRequestsController < ApplicationController
4
+ before_action :require_raising!
5
+
6
+ def index
7
+ @requests = paginate(own.recent_first)
8
+ end
9
+
10
+ def show
11
+ @request = own.find(params[:id])
12
+ end
13
+
14
+ def new
15
+ @request = HrRequest.new
16
+ end
17
+
18
+ def create
19
+ @request = HrRequest.new(request_params.merge(user_id: hr_current_user.id))
20
+ if @request.save
21
+ redirect_to hr_requests_path, notice: "Sent to HR."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def cancel
28
+ request = own.find(params[:id])
29
+ request.cancel!(actor: hr_current_user)
30
+ redirect_to hr_requests_path, notice: "Request withdrawn."
31
+ rescue ActiveRecord::RecordInvalid
32
+ redirect_to hr_requests_path, alert: "That request has already been answered."
33
+ end
34
+
35
+ private
36
+
37
+ def require_raising! = hr_require_permission!("hr_request.raise")
38
+
39
+ def own = HrRequest.where(user_id: hr_current_user.id)
40
+
41
+ def request_params = params.require(:hr_request).permit(:category, :subject, :body)
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ module HrLite
2
+ # What people are held to, and the button that says they have read it.
3
+ class PoliciesController < ApplicationController
4
+ before_action :require_reading!
5
+
6
+ def index
7
+ @policies = Policy.current.sort_by(&:title)
8
+ @acknowledged = PolicyAcknowledgement.where(user_id: hr_current_user.id)
9
+ .pluck(:policy_id).to_set
10
+ end
11
+
12
+ def show
13
+ @policy = Policy.published.find(params[:id])
14
+ end
15
+
16
+ def acknowledge
17
+ policy = Policy.published.find(params[:id])
18
+ policy.acknowledge!(hr_current_user)
19
+ redirect_to policies_path, notice: "Thank you — recorded against #{policy.title} v#{policy.version}."
20
+ end
21
+
22
+ private
23
+
24
+ def require_reading! = hr_require_permission!("policy.view", scope: :all)
25
+ end
26
+ end
@@ -12,6 +12,10 @@ module HrLite
12
12
  # Employee tier all the same — holding an approval IS the authorisation,
13
13
  # so a manager and a stand-in covering for one reach the same screen.
14
14
  { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
15
+ { label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
16
+ { label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
17
+ { label: "Policies", path: :policies_path, match: [ "/policies" ] },
18
+ { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ] },
15
19
  { label: "Org", path: :org_path, match: [ "/org" ] },
16
20
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
17
21
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
@@ -21,7 +25,8 @@ module HrLite
21
25
  ADMIN_NAV_ITEMS = [
22
26
  { label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
23
27
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
24
- { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] }
28
+ { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
29
+ { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] }
25
30
  ].freeze
26
31
 
27
32
  LEADERSHIP_NAV_ITEMS = [
@@ -22,7 +22,7 @@ module HrLite
22
22
  def self.approvable_types
23
23
  %w[
24
24
  HrLite::LeaveRequest HrLite::CompOffRequest
25
- HrLite::RegularizationRequest HrLite::Resignation
25
+ HrLite::RegularizationRequest HrLite::Resignation HrLite::Expense
26
26
  ].freeze
27
27
  end
28
28
 
@@ -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