hr_lite 0.5.2 → 0.6.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
  5. data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
  6. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
  7. data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
  8. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
  10. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  11. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  12. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  13. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  14. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
  15. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  16. data/app/controllers/hr_lite/application_controller.rb +39 -1
  17. data/app/helpers/hr_lite/application_helper.rb +5 -2
  18. data/app/models/hr_lite/audit_log.rb +26 -1
  19. data/app/models/hr_lite/comp_off_request.rb +3 -1
  20. data/app/models/hr_lite/leave_request.rb +27 -4
  21. data/app/models/hr_lite/payroll_run.rb +30 -4
  22. data/app/models/hr_lite/regularization_request.rb +3 -4
  23. data/app/models/hr_lite/role.rb +66 -0
  24. data/app/models/hr_lite/role_assignment.rb +18 -0
  25. data/app/models/hr_lite/role_grant.rb +15 -0
  26. data/app/models/hr_lite/salary_slip.rb +1 -2
  27. data/app/services/hr_lite/access.rb +111 -0
  28. data/app/services/hr_lite/payroll_run_processor.rb +4 -1
  29. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  30. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  31. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  32. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  35. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  36. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  37. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  38. data/lib/hr_lite/configuration.rb +11 -7
  39. data/lib/hr_lite/current.rb +4 -0
  40. data/lib/hr_lite/financial_year.rb +26 -0
  41. data/lib/hr_lite/permissions.rb +79 -0
  42. data/lib/hr_lite/role_seeds.rb +81 -0
  43. data/lib/hr_lite/statutory_rate_card.rb +45 -5
  44. data/lib/hr_lite/version.rb +1 -1
  45. data/lib/hr_lite.rb +81 -11
  46. data/lib/tasks/hr_lite_tasks.rake +10 -2
  47. metadata +17 -1
@@ -0,0 +1,53 @@
1
+ <% content_for(:page_title) { @role.name } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title"><%= @role.name %></h1>
4
+ <% unless @role.system? %>
5
+ <div class="hrl-page__actions">
6
+ <%= button_to "Delete role", hr_lite.admin_role_path(@role), method: :delete,
7
+ class: "hrl-btn hrl-btn--danger",
8
+ form: { data: { turbo_confirm: "Delete #{@role.name}? Everyone holding it loses that access." } } %>
9
+ </div>
10
+ <% end %>
11
+ </div>
12
+
13
+ <%= render "form", role: @role, url: hr_lite.admin_role_path(@role), method: :patch %>
14
+
15
+ <section class="hrl-card">
16
+ <h2 class="hrl-card__title">Who holds this role</h2>
17
+ <% assignments = @role.role_assignments.includes(:user).sort_by { |a| hr_display_name(a.user).downcase } %>
18
+ <% if assignments.empty? %>
19
+ <p class="hrl-muted">Nobody yet.</p>
20
+ <% else %>
21
+ <div class="hrl-table-wrap">
22
+ <table class="hrl-table hrl-table--stack">
23
+ <thead><tr><th>Person</th><th>Added</th><th></th></tr></thead>
24
+ <tbody>
25
+ <% assignments.each do |assignment| %>
26
+ <tr>
27
+ <td data-label="Person"><%= hr_display_name(assignment.user) %></td>
28
+ <td data-label="Added"><%= assignment.created_at.to_date.strftime("%d %b %Y") %></td>
29
+ <td data-label="">
30
+ <%= button_to "Remove", hr_lite.admin_role_assignment_path(@role, assignment),
31
+ method: :delete, class: "hrl-btn",
32
+ form: { data: { turbo_confirm: "Remove #{hr_display_name(assignment.user)} from #{@role.name}?" } } %>
33
+ </td>
34
+ </tr>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+ </div>
39
+ <% end %>
40
+
41
+ <%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true do %>
42
+ <div class="hrl-field">
43
+ <%= label_tag :user_id, "Add somebody to this role" %>
44
+ <% held = @role.role_assignments.map(&:user_id) %>
45
+ <%= select_tag :user_id,
46
+ options_for_select(HrLite.employees.reject { |u| held.include?(u.id) }
47
+ .map { |u| [ hr_display_name(u), u.id ] }) %>
48
+ </div>
49
+ <div class="hrl-form-actions">
50
+ <%= submit_tag "Add", class: "hrl-btn hrl-btn--primary" %>
51
+ </div>
52
+ <% end %>
53
+ </section>
@@ -0,0 +1,49 @@
1
+ <% content_for(:page_title) { "Roles" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Roles &amp; permissions</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_role_path %>">Add role</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <p class="hrl-muted hrl-small">
11
+ A permission is what somebody may do; its scope is whose records they may
12
+ do it to — only their own, their reports', or everyone's.
13
+ </p>
14
+ <div class="hrl-table-wrap">
15
+ <table class="hrl-table hrl-table--stack">
16
+ <thead><tr><th>Role</th><th>People</th><th>Reaches</th><th></th></tr></thead>
17
+ <tbody>
18
+ <% @roles.each do |role| %>
19
+ <tr>
20
+ <td data-label="Role">
21
+ <strong><%= role.name %></strong>
22
+ <% if role.system? %><span class="hrl-badge hrl-badge--muted">Built-in</span><% end %>
23
+ <% if role.description.present? %>
24
+ <div class="hrl-small hrl-muted"><%= role.description %></div>
25
+ <% end %>
26
+ </td>
27
+ <td data-label="People" class="hrl-num"><%= role.role_assignments.size %></td>
28
+ <td data-label="Reaches">
29
+ <% company_wide = role.role_grants.count { |g| g.scope == "all" } %>
30
+ <% team = role.role_grants.count { |g| g.scope == "team" } %>
31
+ <%= role.role_grants.size %> permission<%= "s" unless role.role_grants.size == 1 %>
32
+ <%# Joined rather than concatenated: a role with team grants and
33
+ no company-wide ones rendered a leading "·" with nothing
34
+ in front of it. %>
35
+ <% reach = [ ("#{company_wide} company-wide" if company_wide.positive?),
36
+ ("#{team} own team" if team.positive?) ].compact %>
37
+ <% if reach.any? %>
38
+ <span class="hrl-small hrl-muted"><%= reach.join(" · ") %></span>
39
+ <% end %>
40
+ </td>
41
+ <td data-label="">
42
+ <a class="hrl-small" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
43
+ </td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ </section>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New role" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">New role</h1></div>
3
+ <%= render "form", role: @role, url: hr_lite.admin_roles_path, method: :post %>
data/config/routes.rb CHANGED
@@ -46,6 +46,9 @@ HrLite::Engine.routes.draw do
46
46
  resources :regularization_requests, only: %i[index show] do
47
47
  member { post :approve; post :reject }
48
48
  end
49
+ resources :roles, except: :show do
50
+ resources :assignments, only: %i[create destroy], controller: "role_assignments"
51
+ end
49
52
  resources :leave_types, except: :show
50
53
  resources :office_locations, except: :show
51
54
  resources :holidays, only: %i[index create update destroy] do
@@ -0,0 +1,63 @@
1
+ class AddStatusConstraintsAndAppraisalFkToHrLite < ActiveRecord::Migration[8.1]
2
+ # Every status column in the engine is a plain string validated only in
3
+ # Ruby, so anything that skips validations — update_column, update_all,
4
+ # insert_all, a console fix, a future migration — can write a value no
5
+ # screen renders and no transition accepts. These constraints put the
6
+ # allowed set somewhere it cannot be bypassed.
7
+ #
8
+ # Deliberately in step with each model's own STATUSES array: adding a
9
+ # status now means writing a migration, which is the point.
10
+ STATUSES = {
11
+ hr_lite_attendance_records: %w[present half_day],
12
+ hr_lite_leave_requests: %w[pending approved rejected cancelled],
13
+ hr_lite_comp_off_requests: %w[pending approved rejected cancelled],
14
+ hr_lite_regularization_requests: %w[pending approved rejected cancelled],
15
+ hr_lite_resignations: %w[pending accepted withdrawn],
16
+ hr_lite_appraisals: %w[draft shared],
17
+ hr_lite_payroll_runs: %w[draft processing review finalized published]
18
+ }.freeze
19
+
20
+ def up
21
+ # On an install that already holds an off-list value the constraint build
22
+ # fails with a bare integrity error naming neither the row nor the value.
23
+ # Say what is actually in the way instead — this runs on live data.
24
+ offenders = STATUSES.filter_map do |table, statuses|
25
+ found = select_values(
26
+ "SELECT DISTINCT status FROM #{table} WHERE status NOT IN (#{quoted(statuses)})"
27
+ )
28
+ "#{table}: #{found.join(', ')}" if found.any?
29
+ end
30
+
31
+ if offenders.any?
32
+ raise ActiveRecord::IrreversibleMigration,
33
+ "Cannot constrain status columns — these rows hold values no model " \
34
+ "declares. Correct them first:\n #{offenders.join("\n ")}"
35
+ end
36
+
37
+ STATUSES.each do |table, statuses|
38
+ add_check_constraint table, status_sql(statuses), name: constraint_name(table)
39
+ end
40
+
41
+ # has_one :designation_change, dependent: :nullify — with neither an index
42
+ # nor a foreign key. Destroying an appraisal scanned the whole table to
43
+ # find its promotion row, and nothing stopped that row from being left
44
+ # pointing at an appraisal that no longer exists.
45
+ add_index :hr_lite_designation_changes, :appraisal_id
46
+ add_foreign_key :hr_lite_designation_changes, :hr_lite_appraisals,
47
+ column: :appraisal_id, on_delete: :nullify
48
+ end
49
+
50
+ def down
51
+ remove_foreign_key :hr_lite_designation_changes, column: :appraisal_id
52
+ remove_index :hr_lite_designation_changes, :appraisal_id
53
+ STATUSES.each_key { |table| remove_check_constraint table, name: constraint_name(table) }
54
+ end
55
+
56
+ private
57
+
58
+ def status_sql(statuses) = "status IN (#{quoted(statuses)})"
59
+
60
+ def quoted(statuses) = statuses.map { |s| connection.quote(s) }.join(", ")
61
+
62
+ def constraint_name(table) = "#{table}_status_check"
63
+ end
@@ -0,0 +1,49 @@
1
+ class CreateHrLiteRolesAndGrants < ActiveRecord::Migration[8.1]
2
+ # Roles replace the three configured lambdas (admin_check, leadership_emails,
3
+ # superadmin_emails). Two of those matched a MUTABLE email string — a
4
+ # permission stored in a column the host lets people edit.
5
+ #
6
+ # Permissions themselves are not a table: the vocabulary is declared in
7
+ # HrLite::Permissions::REGISTRY, so a grant is a (role, key, scope) row and
8
+ # an unknown key cannot be stored. That keeps seeding idempotent, and makes
9
+ # retiring a permission a code change with a spec rather than silent data.
10
+ def change
11
+ create_table :hr_lite_roles do |t|
12
+ t.string :name, null: false
13
+ t.string :description
14
+ # Roles the engine seeds and refers to by name. Renaming or deleting one
15
+ # is refused by the model — the upgrade path off the email lists lands
16
+ # people in these, and the roles screen has to keep meaning something.
17
+ t.boolean :system, null: false, default: false
18
+ t.timestamps
19
+ end
20
+ add_index :hr_lite_roles, :name, unique: true
21
+
22
+ create_table :hr_lite_role_grants do |t|
23
+ t.references :role, null: false, index: false,
24
+ foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
25
+ t.string :permission_key, null: false
26
+ # self | team | all — see HrLite::Permissions. Stored per grant so one
27
+ # key covers "own leave", "my reports' leave" and "everyone's leave".
28
+ t.string :scope, null: false, default: "self"
29
+ t.timestamps
30
+ end
31
+ # One scope per (role, permission): a role either reaches a set of rows or
32
+ # it does not, and two rows for one key would make that a lookup race.
33
+ add_index :hr_lite_role_grants, %i[role_id permission_key], unique: true
34
+ add_check_constraint :hr_lite_role_grants, "scope IN ('self', 'team', 'all')",
35
+ name: "hr_lite_role_grants_scope_check"
36
+
37
+ create_table :hr_lite_role_assignments do |t|
38
+ # No FK to the host user table — its name is host-specific, the same
39
+ # reason every other user_id in this engine is a bare bigint.
40
+ t.bigint :user_id, null: false
41
+ t.references :role, null: false, index: false,
42
+ foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
43
+ t.bigint :granted_by_id
44
+ t.timestamps
45
+ end
46
+ add_index :hr_lite_role_assignments, %i[user_id role_id], unique: true
47
+ add_index :hr_lite_role_assignments, :role_id
48
+ end
49
+ end
@@ -0,0 +1,82 @@
1
+ class SeedHrLiteRolesFromEmailTiers < ActiveRecord::Migration[8.1]
2
+ # The upgrade path off the email lists. On an install that is already
3
+ # running, this is the migration that decides whether anyone loses access,
4
+ # so it is deliberately generous: everybody who could reach something
5
+ # before can reach at least as much afterwards.
6
+ #
7
+ # config.superadmin_emails -> Super Admin
8
+ # config.leadership_emails -> Leadership
9
+ # admin_check == true -> HR
10
+ # everybody else -> Employee
11
+ #
12
+ # It reads the host's OWN configuration, so nothing has to be typed twice,
13
+ # and it says out loud what it did — an upgrade that silently grants the
14
+ # money tier to the wrong person is the failure worth being loud about.
15
+ def up
16
+ require "hr_lite/role_seeds"
17
+ created = HrLite::RoleSeeds.call
18
+ say "Seeded roles: #{created.join(', ')}" if created.any?
19
+
20
+ # A host that has already assigned roles by hand is not re-derived from
21
+ # email lists — that would undo their work.
22
+ if HrLite::RoleAssignment.exists?
23
+ say "Role assignments already exist — leaving them alone."
24
+ return
25
+ end
26
+
27
+ roles = HrLite::Role.where(name: [
28
+ HrLite::Role::EMPLOYEE, HrLite::Role::HR,
29
+ HrLite::Role::LEADERSHIP, HrLite::Role::SUPER_ADMIN
30
+ ]).index_by(&:name)
31
+
32
+ # The legacy predicates, read directly rather than through HrLite.admin?
33
+ # — which by now answers off roles, and would say no to everyone.
34
+ superadmins = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
35
+ leaders = HrLite.normalize_email_list(HrLite.config.leadership_emails)
36
+ # Pre-0.5.0 behaviour, still live on hosts that never set the money list:
37
+ # an empty superadmin list meant "the same people as leadership".
38
+ superadmins = leaders if superadmins.empty?
39
+
40
+ assigned = Hash.new { |hash, key| hash[key] = [] }
41
+
42
+ HrLite.config.employees_scope.call.find_each do |user|
43
+ address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
44
+ names = [ HrLite::Role::EMPLOYEE ]
45
+ names << HrLite::Role::SUPER_ADMIN if address.present? && superadmins.include?(address)
46
+ names << HrLite::Role::LEADERSHIP if address.present? && leaders.include?(address)
47
+ names << HrLite::Role::HR if legacy_admin?(user)
48
+
49
+ names.uniq.each do |name|
50
+ role = roles[name] or next
51
+
52
+ HrLite::RoleAssignment.create!(user_id: user.id, role: role)
53
+ assigned[name] << HrLite.display_name(user)
54
+ end
55
+ end
56
+
57
+ if assigned.empty?
58
+ say "No users matched employees_scope — assign roles from the Roles screen."
59
+ else
60
+ assigned.each { |name, people| say "#{name}: #{people.sort.join(', ')}" }
61
+ end
62
+ say "Set `config.legacy_tier_checks = true` to keep the old email lists in " \
63
+ "charge for now; it is honoured until 0.7.0."
64
+ end
65
+
66
+ # Assignments only — the roles themselves stay, because dropping them would
67
+ # take every hand-made grant with them.
68
+ def down
69
+ HrLite::RoleAssignment.delete_all
70
+ end
71
+
72
+ private
73
+
74
+ # admin_check is a host lambda over the host's own user model; a host whose
75
+ # users do not answer it must not break the whole upgrade.
76
+ def legacy_admin?(user)
77
+ !!HrLite.config.admin_check.call(user)
78
+ rescue StandardError => e
79
+ say "admin_check raised for #{HrLite.display_name(user)} (#{e.class}) — treated as not HR."
80
+ false
81
+ end
82
+ end
@@ -7,13 +7,19 @@ HrLite.configure do |c|
7
7
  c.current_user_method = :current_user
8
8
  c.authenticate_method = :authenticate_user!
9
9
 
10
- # Operations tier (team attendance, leave decisions, overview board).
11
- c.admin_check = ->(user) { user.respond_to?(:admin?) && user.admin? }
12
-
13
- # Governing tier ONLY these people change policy, employee profiles,
14
- # salary structures, payroll and appraisals. Keep it in an env var so
15
- # changing leadership never needs a deploy.
16
- c.leadership_emails = ENV.fetch("HR_LEADERSHIP_EMAILS", "").split(",").map(&:strip)
10
+ # Access is a role table, not configuration `rake hr_lite:seed` creates
11
+ # the built-in roles and you assign people from /admin/roles. The first
12
+ # person needs Super Admin, which the upgrade migration grants on an
13
+ # existing install; on a fresh one, from a console:
14
+ #
15
+ # HrLite::RoleAssignment.create!(
16
+ # user_id: User.find_by!(email: "you@example.com").id,
17
+ # role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
18
+ #
19
+ # Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
20
+ # your leadership_emails / superadmin_emails / admin_check as they were.
21
+ # It is honoured until 0.7.0.
22
+ # c.legacy_tier_checks = true
17
23
 
18
24
  # Where the portal is reachable (subdomain or path). Enables email link
19
25
  # buttons and HrLite.public_url / HrLite.public_url? for deep links.
@@ -10,7 +10,8 @@ module HrLite
10
10
  :leadership_emails, :leadership_check, :extra_stylesheets,
11
11
  :superadmin_emails, :superadmin_check,
12
12
  :mailer_from, :public_url_base, :notification_matrix, :back_link,
13
- :onboard_user, :offboard_user, :invite_url_for
13
+ :onboard_user, :offboard_user, :invite_url_for,
14
+ :legacy_tier_checks
14
15
 
15
16
  attr_reader :leave_year_start_month
16
17
 
@@ -45,17 +46,20 @@ module HrLite
45
46
  @time_zone = "Asia/Kolkata"
46
47
  @currency_symbol = "₹"
47
48
  @on_designation_change = ->(user, designation) { }
49
+ # Access came from three lambdas before 0.6.0, two of which matched the
50
+ # user's EMAIL — a mutable, host-owned, unverified column that a host
51
+ # then had to remember never to let anyone edit. Roles replace them.
52
+ # This flag hands authority back to the old lambdas for a host that has
53
+ # not finished migrating; honoured for one minor version, gone in 0.7.0.
54
+ @legacy_tier_checks = false
48
55
  @leadership_emails = []
49
- @leadership_check = ->(user) do
50
- emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
51
- emails.include?(user.email.to_s.downcase)
52
- end
56
+ @leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
53
57
  # Money tier: salary structures, payroll, slips, appraisals. Empty
54
58
  # list means "same as leadership" (pre-0.5.0 behaviour).
55
59
  @superadmin_emails = []
56
60
  @superadmin_check = ->(user) do
57
- emails = HrLite.config.superadmin_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
58
- emails.empty? ? HrLite.leadership?(user) : emails.include?(user.email.to_s.downcase)
61
+ list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
62
+ list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
59
63
  end
60
64
  @extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
61
65
  @mailer_from = "hr@example.com"
@@ -3,5 +3,9 @@ module HrLite
3
3
  # readable from any model callback without threading it through.
4
4
  class Current < ActiveSupport::CurrentAttributes
5
5
  attribute :actor
6
+ # Resolved permissions, keyed by user id. Every screen asks several times
7
+ # per request and resolution joins three tables; CurrentAttributes is
8
+ # reset between requests, so a role change takes effect on the next one.
9
+ attribute :access_cache
6
10
  end
7
11
  end
@@ -0,0 +1,26 @@
1
+ module HrLite
2
+ # The Indian financial year runs 1 April – 31 March. Three places worked
3
+ # this out for themselves (the slip's year-to-date sums, the TDS projector
4
+ # and the rate card); it lives here once so they cannot drift apart.
5
+ module FinancialYear
6
+ START_MONTH = 4
7
+
8
+ # First day of the FY containing `date`. March 2027 belongs to the year
9
+ # that opened on 1 April 2026.
10
+ def self.start_for(date)
11
+ year = date.month >= START_MONTH ? date.year : date.year - 1
12
+ Date.new(year, START_MONTH, 1)
13
+ end
14
+
15
+ # "2026-27" — for warnings and screens, never for arithmetic.
16
+ def self.label(date)
17
+ start = start_for(date)
18
+ "#{start.year}-#{format('%02d', (start.year + 1) % 100)}"
19
+ end
20
+
21
+ # True when `date` falls in an earlier FY than `other`.
22
+ def self.before?(date, other)
23
+ start_for(date) < start_for(other)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,79 @@
1
+ module HrLite
2
+ # The whole vocabulary of the engine's authorization, in one place.
3
+ #
4
+ # A permission is a KEY plus a SCOPE. The key says what the action is
5
+ # ("approve leave"); the scope says whose rows it reaches:
6
+ #
7
+ # self — only this person's own records
8
+ # team — the people who report to them (EmployeeProfile#manager_id)
9
+ # all — everyone in the company
10
+ #
11
+ # That is why there is no `leave.approve_own` / `leave.approve_team` /
12
+ # `leave.approve_all` triple: one key with three possible scopes says the
13
+ # same thing without three times the surface to get wrong.
14
+ #
15
+ # Keys are DATA — a role grants them from the database. They are declared
16
+ # here so that a typo in a controller is a boot-time failure rather than a
17
+ # permission that silently never matches.
18
+ module Permissions
19
+ SCOPES = %i[self team all].freeze
20
+
21
+ # Ordered weakest to strongest. `all` satisfies a `team` requirement,
22
+ # `team` satisfies `self`; never the other way round.
23
+ SCOPE_RANK = { self: 0, team: 1, all: 2 }.freeze
24
+
25
+ # key => [ group, description ]. The group is what the roles screen
26
+ # renders as a heading; the description is the sentence beside the
27
+ # checkbox, so it is written for the person granting it.
28
+ REGISTRY = {
29
+ "profile.view" => [ "People", "See employee profiles" ],
30
+ "profile.manage" => [ "People", "Create and edit employee profiles, onboard and offboard" ],
31
+ "attendance.view" => [ "Attendance", "See attendance records" ],
32
+ "attendance.manage" => [ "Attendance", "Correct punches and decide regularization tickets" ],
33
+ "leave.request" => [ "Leave", "Apply for leave and comp-off" ],
34
+ "leave.view" => [ "Leave", "See leave requests and balances" ],
35
+ "leave.approve" => [ "Leave", "Approve, reject and cancel leave and comp-off" ],
36
+ "leave.manage" => [ "Leave", "Adjust balances, configure leave types and holidays" ],
37
+ "payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
38
+ "payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
39
+ "payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
40
+ "salary.view" => [ "Payroll", "See salary structures" ],
41
+ "salary.manage" => [ "Payroll", "Set and revise salary structures" ],
42
+ "appraisal.view" => [ "Growth", "See appraisals" ],
43
+ "appraisal.manage" => [ "Growth", "Write, share and act on appraisals and promotions" ],
44
+ "resignation.view" => [ "Lifecycle", "See resignations" ],
45
+ "resignation.manage" => [ "Lifecycle", "Accept resignations and set the last working day" ],
46
+ "settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
47
+ "audit.view" => [ "Administration", "Read the audit trail" ],
48
+ "audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
49
+ "role.manage" => [ "Administration", "Create roles and grant permissions" ]
50
+ }.freeze
51
+
52
+ KEYS = REGISTRY.keys.freeze
53
+
54
+ def self.valid?(key) = REGISTRY.key?(key.to_s)
55
+
56
+ # Raises rather than returning false: an unknown key in a controller is a
57
+ # typo that would otherwise read as "nobody may do this", which fails in
58
+ # the safe direction but silently, and is then very hard to find.
59
+ def self.validate!(key)
60
+ return key.to_s if valid?(key)
61
+
62
+ raise ArgumentError, "Unknown HrLite permission #{key.inspect}. " \
63
+ "Declare it in HrLite::Permissions::REGISTRY first."
64
+ end
65
+
66
+ def self.group(key) = REGISTRY.fetch(key.to_s).first
67
+ def self.description(key) = REGISTRY.fetch(key.to_s).last
68
+
69
+ def self.grouped
70
+ REGISTRY.group_by { |_key, (group, _)| group }
71
+ .transform_values { |pairs| pairs.map(&:first) }
72
+ end
73
+
74
+ # Does `held` satisfy a requirement for `needed`?
75
+ def self.scope_covers?(held, needed)
76
+ SCOPE_RANK.fetch(held.to_sym) >= SCOPE_RANK.fetch(needed.to_sym)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,81 @@
1
+ module HrLite
2
+ # The six roles an install starts with. They are a STARTING POINT, not a
3
+ # ladder in code: an install is expected to edit the grants, and `roles:seed`
4
+ # never overwrites a role that already exists.
5
+ #
6
+ # Read the scopes as the interesting part. Manager and HR hold the same
7
+ # leave.approve key — the difference between them is `team` and `all`, which
8
+ # is the whole reason scope lives on the grant.
9
+ module RoleSeeds
10
+ # A method, not a constant: the keys are Role constants, and this file is
11
+ # required while the gem loads, long before Active Record models exist.
12
+ def self.definitions
13
+ {
14
+ Role::EMPLOYEE => {
15
+ description: "Self-service only: own attendance, leave, documents and payslips.",
16
+ grants: {
17
+ "leave.request" => "self", "leave.view" => "self",
18
+ "attendance.view" => "self", "payroll.view" => "self",
19
+ "profile.view" => "self", "appraisal.view" => "self",
20
+ "resignation.view" => "self"
21
+ }
22
+ },
23
+ Role::MANAGER => {
24
+ description: "Everything an employee has, plus their reports' attendance and leave.",
25
+ grants: {
26
+ "leave.request" => "self", "leave.view" => "team", "leave.approve" => "team",
27
+ "attendance.view" => "team", "attendance.manage" => "team",
28
+ "payroll.view" => "self", "profile.view" => "team",
29
+ "appraisal.view" => "self", "resignation.view" => "self"
30
+ }
31
+ },
32
+ Role::HR => {
33
+ description: "Day-to-day operations for everyone: attendance, leave, holidays, tickets.",
34
+ grants: {
35
+ "leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
36
+ "leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
37
+ "profile.view" => "all", "payroll.view" => "self",
38
+ "appraisal.view" => "self", "resignation.view" => "all"
39
+ }
40
+ },
41
+ Role::FINANCE => {
42
+ description: "Payroll and pay data. No authority over people or policy.",
43
+ grants: {
44
+ "leave.request" => "self", "leave.view" => "self",
45
+ "attendance.view" => "all", "profile.view" => "all",
46
+ "payroll.view" => "all", "payroll.manage" => "all", "payroll.export" => "all",
47
+ "salary.view" => "all", "salary.manage" => "all",
48
+ "audit.view" => "all", "audit.view_money" => "all"
49
+ }
50
+ },
51
+ Role::LEADERSHIP => {
52
+ description: "People and policy for everyone — deliberately NOT pay.",
53
+ grants: {
54
+ "leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
55
+ "leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
56
+ "profile.view" => "all", "profile.manage" => "all",
57
+ "resignation.view" => "all", "resignation.manage" => "all",
58
+ "settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self"
59
+ }
60
+ },
61
+ Role::SUPER_ADMIN => {
62
+ description: "Everything, including pay, appraisals and who holds which role.",
63
+ grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }
64
+ }
65
+ }.freeze
66
+ end
67
+
68
+ # Creates any role that does not exist yet and leaves every existing one
69
+ # exactly as the install has tuned it — the same contract as the leave-type
70
+ # seed. Returns the names it created.
71
+ def self.call
72
+ definitions.filter_map do |name, definition|
73
+ next if Role.exists?(name: name)
74
+
75
+ role = Role.create!(name: name, description: definition[:description], system: true)
76
+ role.replace_grants!(definition[:grants])
77
+ name
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,9 +3,13 @@ module HrLite
3
3
  # is a one-hash edit that gets code review and a spec diff, never an
4
4
  # inline-constant hunt.
5
5
  #
6
- # VERIFY WITH A CA before the first run of any new financial year; the
7
- # FY 2026-27 card ships with FY 2025-26 figures pending confirmation of
8
- # the Feb 2026 Finance Act.
6
+ # VERIFY WITH A CA before the first run of any new financial year. Adding
7
+ # a year is one new dated entry in CARDS; nothing else changes.
8
+ #
9
+ # When no card exists for a run's financial year the lookup falls back to
10
+ # the newest one it has — payroll cannot simply stop every April — but
11
+ # `warning_for` says so on the run, and `PayrollRunProcessor` puts that
12
+ # sentence in front of every other warning until a card is added.
9
13
  module StatutoryRateCard
10
14
  def self.r(value) = BigDecimal(value.to_s)
11
15
  private_class_method :r
@@ -58,8 +62,44 @@ module HrLite
58
62
  }.freeze
59
63
 
60
64
  def self.for(period_month)
61
- effective = CARDS.keys.sort.reverse.find { |date| date <= period_month }
62
- effective ? CARDS[effective] : CARDS[CARDS.keys.min]
65
+ CARDS[effective_date_for(period_month)]
66
+ end
67
+
68
+ # Which card a run will actually use. A month older than every card
69
+ # borrows the earliest one — see `predates_cards?`, which says so.
70
+ def self.effective_date_for(period_month)
71
+ CARDS.keys.sort.reverse.find { |date| date <= period_month } || CARDS.keys.min
72
+ end
73
+
74
+ # The card in force is from an EARLIER financial year than the run. The
75
+ # lookup still returns figures — it has to, or payroll would stop dead
76
+ # every April — so the run carries the warning instead.
77
+ def self.stale_for?(period_month)
78
+ FinancialYear.before?(effective_date_for(period_month), period_month)
79
+ end
80
+
81
+ # The run is older than every card we ship, so it is being computed on
82
+ # rates that had not been announced yet.
83
+ def self.predates_cards?(period_month)
84
+ period_month < CARDS.keys.min
85
+ end
86
+
87
+ # One sentence naming both the run's FY and the card's, or nil when they
88
+ # match. Rendered verbatim in the run's warnings list.
89
+ def self.warning_for(period_month)
90
+ card_fy = FinancialYear.label(effective_date_for(period_month))
91
+ run_fy = FinancialYear.label(period_month)
92
+
93
+ if predates_cards?(period_month)
94
+ "Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
95
+ "statutory card — no card ships for a year that early. PF, ESI, PT " \
96
+ "and TDS on this run are not the rates that applied. Verify with your CA."
97
+ elsif stale_for?(period_month)
98
+ "Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
99
+ "statutory card — no card ships for FY #{run_fy} yet. PF, ESI, PT and " \
100
+ "TDS on this run use last year's rates. Add a CA-verified card for " \
101
+ "FY #{run_fy} before publishing."
102
+ end
63
103
  end
64
104
  end
65
105
  end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.5.2"
2
+ VERSION = "0.6.0"
3
3
  end