hr_lite 0.5.3 → 0.7.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
  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_requests_controller.rb +21 -5
  9. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  10. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  11. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  12. data/app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb +97 -0
  13. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  14. data/app/controllers/hr_lite/application_controller.rb +39 -1
  15. data/app/helpers/hr_lite/application_helper.rb +6 -2
  16. data/app/models/hr_lite/professional_tax_slab.rb +41 -0
  17. data/app/models/hr_lite/role.rb +66 -0
  18. data/app/models/hr_lite/role_assignment.rb +18 -0
  19. data/app/models/hr_lite/role_grant.rb +15 -0
  20. data/app/models/hr_lite/statutory_rate_card_record.rb +95 -0
  21. data/app/services/hr_lite/access.rb +111 -0
  22. data/app/services/hr_lite/calculators/professional_tax.rb +34 -5
  23. data/app/services/hr_lite/payroll_run_processor.rb +17 -0
  24. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  25. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  26. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  27. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  28. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +109 -0
  29. data/app/views/hr_lite/admin/statutory_rate_cards/edit.html.erb +4 -0
  30. data/app/views/hr_lite/admin/statutory_rate_cards/index.html.erb +59 -0
  31. data/app/views/hr_lite/admin/statutory_rate_cards/new.html.erb +4 -0
  32. data/config/routes.rb +4 -0
  33. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  34. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  35. data/db/migrate/20260817190159_create_hr_lite_statutory_rate_cards.rb +44 -0
  36. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  37. data/lib/hr_lite/configuration.rb +8 -1
  38. data/lib/hr_lite/current.rb +4 -0
  39. data/lib/hr_lite/permissions.rb +79 -0
  40. data/lib/hr_lite/role_seeds.rb +81 -0
  41. data/lib/hr_lite/statutory_rate_card.rb +55 -10
  42. data/lib/hr_lite/statutory_seeds.rb +71 -0
  43. data/lib/hr_lite/version.rb +1 -1
  44. data/lib/hr_lite.rb +64 -10
  45. data/lib/tasks/hr_lite_tasks.rake +18 -2
  46. metadata +24 -1
@@ -0,0 +1,47 @@
1
+ <section class="hrl-card">
2
+ <%= form_with model: role, url: url, method: method, local: true do |f| %>
3
+ <%= render "hr_lite/shared/form_errors", record: role %>
4
+
5
+ <div class="hrl-field">
6
+ <%= f.label :name %>
7
+ <% if role.system? %>
8
+ <%= f.text_field :name, disabled: true %>
9
+ <span class="hrl-small hrl-muted">
10
+ Built-in roles keep their name — the upgrade path and the seed both
11
+ identify them by it. The permissions below are yours to change.
12
+ </span>
13
+ <% else %>
14
+ <%= f.text_field :name %>
15
+ <% end %>
16
+ </div>
17
+ <div class="hrl-field">
18
+ <%= f.label :description, "Description (shown on the roles list)" %>
19
+ <%= f.text_field :description %>
20
+ </div>
21
+
22
+ <% HrLite::Permissions.grouped.each do |group, keys| %>
23
+ <fieldset>
24
+ <legend><%= group %></legend>
25
+ <% keys.each do |key| %>
26
+ <% held = role.grant_map[key] || "none" %>
27
+ <div class="hrl-field">
28
+ <%= label_tag "grants_#{key.tr('.', '_')}", HrLite::Permissions.description(key) %>
29
+ <%= select_tag "grants[#{key}]",
30
+ options_for_select([
31
+ [ "Not granted", "none" ],
32
+ [ "Their own records", "self" ],
33
+ [ "Their team's records", "team" ],
34
+ [ "Everyone's records", "all" ]
35
+ ], held),
36
+ id: "grants_#{key.tr('.', '_')}" %>
37
+ </div>
38
+ <% end %>
39
+ </fieldset>
40
+ <% end %>
41
+
42
+ <div class="hrl-form-actions">
43
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
44
+ <a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
45
+ </div>
46
+ <% end %>
47
+ </section>
@@ -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 %>
@@ -0,0 +1,109 @@
1
+ <section class="hrl-card">
2
+ <% if local_assigns[:copied_from] %>
3
+ <p class="hrl-muted hrl-small">
4
+ Copied from FY <%= copied_from.financial_year %>. Most years carry PF and
5
+ ESI over untouched and move only the slabs — change what the Finance Act
6
+ changed, and leave the rest.
7
+ </p>
8
+ <% end %>
9
+
10
+ <%= form_with model: card, url: url, method: method, local: true,
11
+ scope: :statutory_rate_card do |f| %>
12
+ <%= render "hr_lite/shared/form_errors", record: card %>
13
+
14
+ <div class="hrl-field">
15
+ <%= f.label :effective_from, "Financial year starts (1 April)" %>
16
+ <%= f.date_field :effective_from %>
17
+ </div>
18
+
19
+ <fieldset>
20
+ <legend>Provident fund</legend>
21
+ <% { employee_rate: "Employee rate (0.12 = 12%)", employer_rate: "Employer rate",
22
+ eps_rate: "EPS rate", wage_ceiling: "PF wage ceiling (₹)",
23
+ eps_wage_ceiling: "EPS wage ceiling (₹)", edli_rate: "EDLI rate",
24
+ edli_ceiling: "EDLI ceiling (₹)", admin_rate: "PF admin rate" }.each do |key, label| %>
25
+ <div class="hrl-field">
26
+ <%= label_tag "statutory_rate_card_pf_#{key}", label %>
27
+ <%= text_field_tag "statutory_rate_card[pf][#{key}]", card.pf[key.to_s],
28
+ id: "statutory_rate_card_pf_#{key}", inputmode: "decimal" %>
29
+ </div>
30
+ <% end %>
31
+ </fieldset>
32
+
33
+ <fieldset>
34
+ <legend>ESI</legend>
35
+ <% { employee_rate: "Employee rate", employer_rate: "Employer rate",
36
+ gross_ceiling: "Gross ceiling (₹)" }.each do |key, label| %>
37
+ <div class="hrl-field">
38
+ <%= label_tag "statutory_rate_card_esi_#{key}", label %>
39
+ <%= text_field_tag "statutory_rate_card[esi][#{key}]", card.esi[key.to_s],
40
+ id: "statutory_rate_card_esi_#{key}", inputmode: "decimal" %>
41
+ </div>
42
+ <% end %>
43
+ </fieldset>
44
+
45
+ <% %w[new old].each do |regime| %>
46
+ <% table = card.income_tax[regime] || {} %>
47
+ <fieldset>
48
+ <legend><%= regime.capitalize %> regime</legend>
49
+ <% { "standard_deduction" => "Standard deduction (₹)",
50
+ "rebate_cap" => "§87A rebate cap (₹)",
51
+ "cess_rate" => "Cess rate (0.04 = 4%)" }.each do |key, label| %>
52
+ <div class="hrl-field">
53
+ <%= label_tag "statutory_rate_card_it_#{regime}_#{key}", label %>
54
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][#{key}]", table[key],
55
+ id: "statutory_rate_card_it_#{regime}_#{key}", inputmode: "decimal" %>
56
+ </div>
57
+ <% end %>
58
+ <div class="hrl-field hrl-field--check">
59
+ <%= hidden_field_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "0" %>
60
+ <%= check_box_tag "statutory_rate_card[income_tax][#{regime}][marginal_relief]", "1",
61
+ table["marginal_relief"] %>
62
+ <%= label_tag "statutory_rate_card_income_tax_#{regime}_marginal_relief",
63
+ "Marginal relief just above the rebate cap" %>
64
+ </div>
65
+
66
+ <p class="hrl-small hrl-muted">
67
+ Slabs, lowest first. Leave the last band's "up to" empty — that is
68
+ the open-ended top rate.
69
+ </p>
70
+ <% slabs = Array(table["slabs"]) %>
71
+ <% (slabs + [ nil, nil ]).each_with_index do |row, index| %>
72
+ <div class="hrl-row">
73
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][from]",
74
+ row&.first, placeholder: "From ₹", inputmode: "decimal" %>
75
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][to]",
76
+ row&.at(1), placeholder: "Up to ₹ (blank = top)", inputmode: "decimal" %>
77
+ <%= text_field_tag "statutory_rate_card[income_tax][#{regime}][slabs][#{index}][rate]",
78
+ row&.last, placeholder: "Rate (0.05)", inputmode: "decimal" %>
79
+ </div>
80
+ <% end %>
81
+ </fieldset>
82
+ <% end %>
83
+
84
+ <fieldset>
85
+ <legend>Sign-off</legend>
86
+ <p class="hrl-small hrl-muted">
87
+ Until a card is signed off, every payroll run computed on it carries a
88
+ warning. Record the person who actually checked the figures.
89
+ </p>
90
+ <div class="hrl-field">
91
+ <%= f.label :verified_by, "Checked by (name)" %>
92
+ <%= f.text_field :verified_by %>
93
+ </div>
94
+ <div class="hrl-field">
95
+ <%= f.label :verified_on, "Checked on" %>
96
+ <%= f.date_field :verified_on %>
97
+ </div>
98
+ <div class="hrl-field">
99
+ <%= f.label :notes, "Notes" %>
100
+ <%= f.text_field :notes %>
101
+ </div>
102
+ </fieldset>
103
+
104
+ <div class="hrl-form-actions">
105
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
106
+ <a class="hrl-btn" href="<%= hr_lite.admin_statutory_rate_cards_path %>">Cancel</a>
107
+ </div>
108
+ <% end %>
109
+ </section>
@@ -0,0 +1,4 @@
1
+ <% content_for(:page_title) { "FY #{@card.financial_year}" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">FY <%= @card.financial_year %></h1></div>
3
+ <%= render "form", card: @card,
4
+ url: hr_lite.admin_statutory_rate_card_path(@card), method: :patch %>
@@ -0,0 +1,59 @@
1
+ <% content_for(:page_title) { "Statutory rates" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Statutory rates</h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_statutory_rate_card_path %>">Add a year</a>
6
+ </div>
7
+ </div>
8
+
9
+ <section class="hrl-card">
10
+ <p class="hrl-muted hrl-small">
11
+ PF, ESI and the income-tax slabs, per financial year. Payroll resolves the
12
+ card that was in force for the month it is computing, so correcting a year
13
+ never changes an earlier one.
14
+ </p>
15
+ <div class="hrl-table-wrap">
16
+ <table class="hrl-table hrl-table--stack">
17
+ <thead><tr><th>Year</th><th>PF ceiling</th><th>ESI ceiling</th><th>Verified</th><th></th></tr></thead>
18
+ <tbody>
19
+ <% @cards.each do |card| %>
20
+ <tr>
21
+ <td data-label="Year">
22
+ <strong>FY <%= card.financial_year %></strong>
23
+ <div class="hrl-small hrl-muted">from <%= card.effective_from.strftime("%d %b %Y") %></div>
24
+ </td>
25
+ <td data-label="PF ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:pf][:wage_ceiling]) %></td>
26
+ <td data-label="ESI ceiling" class="hrl-num"><%= HrLite::Money.format(card.to_card[:esi][:gross_ceiling]) %></td>
27
+ <td data-label="Verified">
28
+ <% if card.verified? %>
29
+ <span class="hrl-badge"><%= card.verified_by %></span>
30
+ <div class="hrl-small hrl-muted"><%= card.verified_on.strftime("%d %b %Y") %></div>
31
+ <% else %>
32
+ <span class="hrl-badge hrl-badge--muted">Not checked</span>
33
+ <% end %>
34
+ </td>
35
+ <td data-label="">
36
+ <a class="hrl-small" href="<%= hr_lite.edit_admin_statutory_rate_card_path(card) %>">Edit</a>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </section>
44
+
45
+ <section class="hrl-card">
46
+ <h2 class="hrl-card__title">Professional tax</h2>
47
+ <% if @states.empty? %>
48
+ <p class="hrl-muted">
49
+ No state bands are configured. Everybody's professional tax is being
50
+ deducted at ₹0 — which is right only if no state you employ in levies it.
51
+ </p>
52
+ <% else %>
53
+ <p class="hrl-muted hrl-small">
54
+ Bands are configured for: <%= @states.map(&:humanize).to_sentence %>.
55
+ An employee whose salary structure names any other state is deducted ₹0,
56
+ and payroll says so on the run.
57
+ </p>
58
+ <% end %>
59
+ </section>
@@ -0,0 +1,4 @@
1
+ <% content_for(:page_title) { "Add a financial year" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">Add a financial year</h1></div>
3
+ <%= render "form", card: @card, copied_from: @copied_from,
4
+ url: hr_lite.admin_statutory_rate_cards_path, method: :post %>
data/config/routes.rb CHANGED
@@ -46,6 +46,10 @@ 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
52
+ resources :statutory_rate_cards, only: %i[index new create edit update]
49
53
  resources :leave_types, except: :show
50
54
  resources :office_locations, except: :show
51
55
  resources :holidays, only: %i[index create update destroy] do
@@ -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
@@ -0,0 +1,44 @@
1
+ class CreateHrLiteStatutoryRateCards < ActiveRecord::Migration[8.1]
2
+ # Statutory rates lived in a frozen hash in Ruby, so adding a financial year
3
+ # meant a gem release. Every April that is a deadline the gem controls and
4
+ # the company does not — and 0.5.3 shipped a warning about exactly that
5
+ # because the FY2026-27 card did not exist.
6
+ #
7
+ # Rates are not secret (they are published by the government), so the
8
+ # figures are plain JSON. What matters is that they are EFFECTIVE-DATED and
9
+ # carry who checked them: a payroll rerun of an old month must resolve to
10
+ # the card that was in force for that month, not to today's.
11
+ def change
12
+ create_table :hr_lite_statutory_rate_cards do |t|
13
+ t.date :effective_from, null: false
14
+ t.json :pf, null: false, default: {}
15
+ t.json :esi, null: false, default: {}
16
+ t.json :income_tax, null: false, default: {}
17
+ # Who signed it off, and when. A card nobody has confirmed is a card
18
+ # payroll should say something about — see SlipBuilder's warnings.
19
+ t.string :verified_by
20
+ t.date :verified_on
21
+ t.text :notes
22
+ t.timestamps
23
+ end
24
+ # One card per date: two would make "which rates applied in June" a
25
+ # question about row order.
26
+ add_index :hr_lite_statutory_rate_cards, :effective_from, unique: true
27
+
28
+ create_table :hr_lite_professional_tax_slabs do |t|
29
+ # Professional tax is a STATE levy. Karnataka was the only state with
30
+ # real slabs in code; every other state silently computed zero, which
31
+ # is a wrong answer delivered confidently.
32
+ t.string :state, null: false
33
+ t.date :effective_from, null: false
34
+ # Inclusive lower bound on monthly earned gross.
35
+ t.decimal :from_amount, precision: 12, scale: 2, null: false
36
+ t.decimal :monthly, precision: 12, scale: 2, null: false
37
+ # Several states collect a larger amount in the last month of the year.
38
+ t.decimal :feb_extra, precision: 12, scale: 2
39
+ t.timestamps
40
+ end
41
+ add_index :hr_lite_professional_tax_slabs, %i[state effective_from from_amount],
42
+ unique: true, name: "index_hr_lite_pt_slabs_on_state_date_and_bound"
43
+ end
44
+ 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.8.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,6 +46,12 @@ 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 more minor version, gone in 0.8.0.
54
+ @legacy_tier_checks = false
48
55
  @leadership_emails = []
49
56
  @leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
50
57
  # Money tier: salary structures, payroll, slips, appraisals. Empty
@@ -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