hr_lite 0.23.1 → 0.26.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +4 -1
  4. data/app/assets/javascripts/hr_lite/tax_preview.js +38 -0
  5. data/app/assets/stylesheets/hr_lite/hr_lite.css +12 -1
  6. data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +76 -0
  7. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +14 -4
  8. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +4 -0
  9. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +6 -16
  10. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +46 -0
  11. data/app/controllers/hr_lite/tax_declarations_controller.rb +19 -1
  12. data/app/controllers/hr_lite/timelines_controller.rb +2 -1
  13. data/app/helpers/hr_lite/application_helper.rb +7 -0
  14. data/app/jobs/hr_lite/ca_review_overdue_job.rb +19 -0
  15. data/app/jobs/hr_lite/ca_review_reminder_job.rb +21 -0
  16. data/app/jobs/hr_lite/payroll_auto_approve_job.rb +21 -0
  17. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +1 -1
  18. data/app/models/hr_lite/audit_log.rb +1 -1
  19. data/app/models/hr_lite/ca_review.rb +66 -0
  20. data/app/models/hr_lite/payroll_run.rb +47 -3
  21. data/app/models/hr_lite/role.rb +3 -0
  22. data/app/models/hr_lite/salary_structure.rb +22 -8
  23. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -6
  24. data/app/models/hr_lite/tax_declaration.rb +24 -0
  25. data/app/models/hr_lite/tax_declaration_item.rb +56 -4
  26. data/app/services/hr_lite/calculators/tds.rb +7 -6
  27. data/app/services/hr_lite/payroll_run_processor.rb +9 -0
  28. data/app/services/hr_lite/slip_builder.rb +1 -1
  29. data/app/services/hr_lite/tax_computation.rb +61 -0
  30. data/app/services/hr_lite/timeline.rb +10 -1
  31. data/app/views/hr_lite/admin/ca_reviews/index.html.erb +94 -0
  32. data/app/views/hr_lite/admin/employees/show.html.erb +3 -1
  33. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +17 -7
  34. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +15 -7
  35. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +1 -1
  36. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -0
  37. data/app/views/hr_lite/admin/tax_declarations/overview.html.erb +39 -0
  38. data/app/views/hr_lite/admin/tax_declarations/person.html.erb +10 -0
  39. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +19 -2
  40. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +6 -4
  41. data/app/views/hr_lite/tax/_computation.html.erb +56 -0
  42. data/app/views/hr_lite/tax_declarations/show.html.erb +61 -59
  43. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  44. data/config/routes.rb +8 -2
  45. data/db/migrate/20261010100000_add_ctc_to_hr_lite_salary_structures.rb +9 -0
  46. data/db/migrate/20261010100100_add_approval_to_hr_lite_payroll_runs.rb +10 -0
  47. data/db/migrate/20261010100200_grant_hr_lite_payroll_approve.rb +22 -0
  48. data/db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb +16 -0
  49. data/db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb +9 -0
  50. data/db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb +12 -0
  51. data/db/migrate/20261010110000_create_hr_lite_ca_reviews.rb +21 -0
  52. data/db/migrate/20261010110100_seed_hr_lite_ca_role.rb +14 -0
  53. data/lib/hr_lite/notifications.rb +5 -0
  54. data/lib/hr_lite/permissions.rb +2 -0
  55. data/lib/hr_lite/role_seeds.rb +9 -3
  56. data/lib/hr_lite/statutory_rate_card.rb +65 -43
  57. data/lib/hr_lite/statutory_seeds.rb +4 -2
  58. data/lib/hr_lite/version.rb +1 -1
  59. data/lib/hr_lite.rb +9 -1
  60. metadata +20 -1
@@ -12,7 +12,7 @@
12
12
  <%= render "hr_lite/shared/form_errors", record: card %>
13
13
 
14
14
  <div class="hrl-field">
15
- <%= f.label :effective_from, "Financial year starts (1 April)" %>
15
+ <%= f.label :effective_from, "Takes effect on (1 April for a new financial year, or the 1st of the month a mid-year change applies from)" %>
16
16
  <%= f.date_field :effective_from %>
17
17
  </div>
18
18
 
@@ -7,6 +7,7 @@
7
7
  <a class="hrl-btn <%= "hrl-btn--primary" if @status == status %>"
8
8
  href="<%= hr_lite.admin_tax_declarations_path(status: status) %>"><%= status.humanize %></a>
9
9
  <% end %>
10
+ <a class="hrl-btn" href="<%= hr_lite.overview_admin_tax_declarations_path %>">Everyone's tax</a>
10
11
  </div>
11
12
  </section>
12
13
 
@@ -0,0 +1,39 @@
1
+ <% content_for(:page_title) { "Everyone's tax" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Everyone's tax — <%= HrLite::FinancialYear.label(Date.current) %></h1>
4
+ <div class="hrl-page__actions"><a class="hrl-btn" href="<%= hr_lite.admin_tax_declarations_path %>">Declarations</a></div>
5
+ </div>
6
+ <p class="hrl-small hrl-muted">Worked out now from each salary structure, the slips already paid and the declaration (limits applied). Open a declaration for the full working.</p>
7
+
8
+ <section class="hrl-card">
9
+ <div class="hrl-table-wrap">
10
+ <table class="hrl-table hrl-table--stack">
11
+ <thead><tr><th>Person</th><th>Regime</th><th>Tax for the year</th><th>Deducted so far</th><th>TDS a month</th><th>Declaration</th></tr></thead>
12
+ <tbody>
13
+ <% @profiles.each do |profile| %>
14
+ <% declaration = @declarations[profile.user_id] %>
15
+ <% tax = HrLite::TaxComputation.new(user: profile.user, declaration: declaration) %>
16
+ <tr>
17
+ <td data-label="Person"><a class="hrl-card__link" href="<%= hr_lite.person_admin_tax_declarations_path(profile.user_id) %>"><%= hr_display_name(profile.user) %></a> <span class="hrl-small hrl-muted"><%= profile.employee_code %></span></td>
18
+ <% if tax.structure %>
19
+ <td data-label="Regime"><%= tax.regime.humanize %><% winner, by = tax.saving %><% if winner != tax.regime && by.positive? %><br><span class="hrl-small hrl-muted"><%= winner.humanize %> saves <%= hrl_rupees(by) %></span><% end %></td>
20
+ <td data-label="Tax for the year" class="hrl-num"><%= hrl_rupees(tax.chosen.annual_tax) %></td>
21
+ <td data-label="Deducted so far" class="hrl-num"><%= hrl_rupees(tax.chosen.tds_paid) %></td>
22
+ <td data-label="TDS a month" class="hrl-num"><%= hrl_rupees(tax.chosen.monthly) %></td>
23
+ <% else %>
24
+ <td data-label="Regime" colspan="4" class="hrl-muted">No salary structure</td>
25
+ <% end %>
26
+ <td data-label="Declaration">
27
+ <% if declaration %>
28
+ <a class="hrl-card__link" href="<%= hr_lite.admin_tax_declaration_path(declaration) %>"><%= declaration.status.humanize %></a>
29
+ <% else %>
30
+ <span class="hrl-muted">Not filed</span>
31
+ <% end %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ </div>
38
+ <%= hrl_pagination %>
39
+ </section>
@@ -0,0 +1,10 @@
1
+ <% content_for(:page_title) { "Tax — #{hr_display_name(@person)}" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Tax — <%= hr_display_name(@person) %></h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.overview_admin_tax_declarations_path %>">Everyone's tax</a>
6
+ <% if @tax.declaration %><a class="hrl-btn" href="<%= hr_lite.admin_tax_declaration_path(@tax.declaration) %>">Declaration</a><% end %>
7
+ </div>
8
+ </div>
9
+ <% unless @tax.declaration %><p class="hrl-small hrl-muted">No declaration filed this year — the old-regime figure carries no investments yet.</p><% end %>
10
+ <%= render "hr_lite/tax/computation", tax: @tax, both: true %>
@@ -17,6 +17,8 @@
17
17
  </p>
18
18
  </section>
19
19
 
20
+ <%= render "hr_lite/tax/computation", tax: @tax, both: true %>
21
+
20
22
  <%= form_with model: @declaration, url: hr_lite.admin_tax_declaration_path(@declaration),
21
23
  method: :patch, scope: :tax_declaration, local: true do |f| %>
22
24
  <section class="hrl-card">
@@ -27,7 +29,18 @@
27
29
  <span class="hrl-small hrl-muted">
28
30
  Claimed <%= hrl_money(item.object.declared_amount) %>
29
31
  <%= "— #{item.object.label}" if item.object.label.present? %>
32
+ <%= "· limit #{hrl_money(item.object.cap)}" if item.object.cap %>
33
+ <%= "· landlord PAN #{item.object.landlord_pan}" if item.object.landlord_pan.present? %>
30
34
  </span>
35
+ <% if item.object.proofs.attached? %>
36
+ <span class="hrl-row">
37
+ <% item.object.proofs.each do |proof| %>
38
+ <a class="hrl-card__link" href="<%= hr_lite.proof_admin_tax_declaration_path(@declaration, proof_id: proof.id) %>">Open <%= proof.filename %></a>
39
+ <% end %>
40
+ </span>
41
+ <% else %>
42
+ <span class="hrl-small hrl-badge hrl-badge--warn">No proof attached</span>
43
+ <% end %>
31
44
  <%= item.text_field :verified_amount,
32
45
  value: item.object.verified_amount&.to_s("F"), inputmode: "decimal",
33
46
  placeholder: "Verified amount" %>
@@ -41,8 +54,12 @@
41
54
 
42
55
  <section class="hrl-card">
43
56
  <h2 class="hrl-card__title">Decide</h2>
44
- <%= button_to "Verify", hr_lite.verify_admin_tax_declaration_path(@declaration),
45
- method: :post, class: "hrl-btn hrl-btn--primary" %>
57
+ <div class="hrl-row">
58
+ <%= button_to "Accept all and verify", hr_lite.accept_all_admin_tax_declaration_path(@declaration),
59
+ method: :post, class: "hrl-btn hrl-btn--primary" %>
60
+ <%= button_to "Verify the amounts recorded above", hr_lite.verify_admin_tax_declaration_path(@declaration),
61
+ method: :post, class: "hrl-btn" %>
62
+ </div>
46
63
  <%= form_with url: hr_lite.reject_admin_tax_declaration_path(@declaration),
47
64
  method: :post, local: true do %>
48
65
  <div class="hrl-field">
@@ -1,10 +1,12 @@
1
- <section class="hrl-card">
2
- <h2 class="hrl-card__title">Leave balance <%= HrLite::LeaveYear.label(HrLite::LeaveYear.current_key) %></h2>
1
+ <%# locals: user (default: you), compact, bare (no card of its own — sits inside another card) %>
2
+ <% user = local_assigns[:user] || hr_current_user %>
3
+ <%= tag.send(local_assigns[:bare] ? :div : :section, class: ("hrl-card" unless local_assigns[:bare])) do %>
4
+ <%= tag.send(local_assigns[:bare] ? :h3 : :h2, class: "hrl-card__title") do %>Leave balance <%= HrLite::LeaveYear.label(HrLite::LeaveYear.current_key) %><% end %>
3
5
  <% types = HrLite::LeaveType.active.where(paid: true).where.not(annual_quota: nil) %>
4
6
  <% if types.any? %>
5
7
  <div class="hrl-row">
6
8
  <% types.each do |type| %>
7
- <% balance = HrLite::LeaveBalance.for(hr_current_user, type, HrLite::LeaveYear.current_key) %>
9
+ <% balance = HrLite::LeaveBalance.for(user, type, HrLite::LeaveYear.current_key) %>
8
10
  <span class="hrl-badge hrl-badge--type" style="--hrl-type: <%= type.color %>">
9
11
  <%= type.name %> · <%= hrl_days(balance.available) %> of <%= hrl_days(balance.entitled) %> left
10
12
  </span>
@@ -16,4 +18,4 @@
16
18
  <% else %>
17
19
  <p class="hrl-muted hrl-small">No leave types configured yet.</p>
18
20
  <% end %>
19
- </section>
21
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <%# locals: tax (HrLite::TaxComputation), both (true on the admin sheet: every regime's working, open) %>
2
+ <% sheet = tax.chosen %>
3
+ <% winner, by = tax.saving %>
4
+ <% names = { "new" => "New regime", "old" => "Old regime" } %>
5
+ <section class="hrl-card" aria-live="polite">
6
+ <h2 class="hrl-card__title">Income tax <%= HrLite::FinancialYear.label(HrLite::FinancialYear.start_for(tax.month)) %></h2>
7
+ <% if tax.structure.nil? %>
8
+ <p class="hrl-muted">No salary structure yet — tax is worked out once one is set.</p>
9
+ <% else %>
10
+ <div class="hrl-kpis hrl-kpis--money">
11
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.annual_tax) %></div><div class="hrl-kpi__label">Tax for the year (<%= names[sheet.regime] %>)</div></div>
12
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.monthly) %></div><div class="hrl-kpi__label">TDS a month, next <%= pluralize(sheet.months_left, "month") %></div></div>
13
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.tds_paid) %></div><div class="hrl-kpi__label">Deducted so far</div></div>
14
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= hrl_rupees(sheet.remaining) %></div><div class="hrl-kpi__label">Still to deduct</div></div>
15
+ </div>
16
+
17
+ <div class="hrl-tax-compare" role="group" aria-label="Old and new regime compared">
18
+ <% tax.sheets.each do |name, s| %>
19
+ <div class="hrl-tax-compare__col<%= " hrl-tax-compare__col--chosen" if name == sheet.regime %>">
20
+ <strong><%= names[name] %></strong><% if name == sheet.regime %> <span class="hrl-badge">Chosen</span><% end %>
21
+ <div class="hrl-tax-compare__amount"><%= hrl_rupees(s.annual_tax) %></div>
22
+ <div class="hrl-small hrl-muted">a year · <%= hrl_rupees(s.monthly) %> a month</div>
23
+ </div>
24
+ <% end %>
25
+ </div>
26
+ <p class="hrl-small">
27
+ <% if by.zero? %>Both regimes cost the same this year.
28
+ <% else %><strong><%= names[winner] %></strong> costs <%= hrl_rupees(by) %> less this year<%= winner == sheet.regime ? " — you are on it." : "." %><% end %>
29
+ </p>
30
+
31
+ <% (both ? tax.sheets.values : [ sheet ]).each do |s| %>
32
+ <details class="hrl-tax-working" <%= "open" if both %>>
33
+ <summary class="hrl-btn">How the <%= names[s.regime].downcase %> tax is worked out</summary>
34
+ <table class="hrl-table hrl-tax-working__table">
35
+ <tbody>
36
+ <tr><td>Salary paid so far this year</td><td class="hrl-num"><%= hrl_rupees(tax.paid_gross) %></td></tr>
37
+ <tr><td>Expected for the rest of the year (<%= hrl_rupees(tax.structure.monthly_gross) %> × <%= s.months_left %>)</td><td class="hrl-num"><%= hrl_rupees(tax.projected_gross) %></td></tr>
38
+ <tr class="hrl-tax-working__total"><td>Gross salary for the year</td><td class="hrl-num"><%= hrl_rupees(s.gross) %></td></tr>
39
+ <% if s.hra_exemption.positive? %><tr><td>Less HRA exemption (from rent)</td><td class="hrl-num">− <%= hrl_rupees(s.hra_exemption) %></td></tr><% end %>
40
+ <tr><td>Less standard deduction</td><td class="hrl-num">− <%= hrl_rupees(s.standard_deduction) %></td></tr>
41
+ <% s.deductions.each do |row| %>
42
+ <tr><td>Less <%= row.label %><br><span class="hrl-small hrl-muted">claimed <%= hrl_rupees(row.claimed) %><%= " · limit #{hrl_rupees(row.cap)}" if row.cap %></span></td><td class="hrl-num">− <%= hrl_rupees(row.allowed) %></td></tr>
43
+ <% end %>
44
+ <tr class="hrl-tax-working__total"><td>Taxable income</td><td class="hrl-num"><%= hrl_rupees(s.taxable) %></td></tr>
45
+ <% s.slabs.select { |row| row[:income].positive? }.each do |row| %>
46
+ <tr><td><%= hrl_rupees(row[:from]) %> – <%= row[:to] ? hrl_rupees(row[:to]) : "above" %> at <%= (row[:rate] * 100).to_i %>% on <%= hrl_rupees(row[:income]) %></td><td class="hrl-num"><%= hrl_rupees(row[:tax]) %></td></tr>
47
+ <% end %>
48
+ <tr class="hrl-tax-working__total"><td>Tax for the year, after any §87A rebate, with 4% cess</td><td class="hrl-num"><%= hrl_rupees(s.annual_tax) %></td></tr>
49
+ <tr><td>Already deducted (TDS)</td><td class="hrl-num">− <%= hrl_rupees(s.tds_paid) %></td></tr>
50
+ <tr class="hrl-tax-working__total"><td>Each remaining month (<%= s.months_left %>)</td><td class="hrl-num"><%= hrl_rupees(s.monthly) %></td></tr>
51
+ </tbody>
52
+ </table>
53
+ </details>
54
+ <% end %>
55
+ <% end %>
56
+ </section>
@@ -1,73 +1,75 @@
1
- <% content_for(:page_title) { "Tax declaration" } %>
1
+ <% content_for(:page_title) { "Tax" } %>
2
2
  <% submittable = @declaration.persisted? && (@declaration.draft? || @declaration.rejected?) %>
3
+ <% locked = @declaration.verified? %>
3
4
  <div class="hrl-page__head">
4
- <h1 class="hrl-page__title">Tax declaration — <%= @declaration.label %></h1>
5
+ <h1 class="hrl-page__title">Tax — <%= @declaration.label %></h1>
5
6
  </div>
6
7
 
7
- <section class="hrl-card">
8
- <p class="hrl-muted hrl-small">
9
- Your whole year's TDS is projected from these numbers, so a declaration
10
- filed in April spreads the tax evenly instead of landing it in March.
11
- Claim what you intend to invest; HR records what the proof supports when
12
- you hand it over.
13
- </p>
14
- <p>
15
- Status: <%= hrl_request_status_badge(@declaration.status == "verified" ? "approved" : @declaration.status) %>
16
- <% if @declaration.note.present? %>
17
- <span class="hrl-small hrl-muted">— <%= @declaration.note %></span>
18
- <% end %>
19
- </p>
20
- </section>
8
+ <div id="hrl-tax-preview"><%= render "hr_lite/tax/computation", tax: @tax, both: false %></div>
21
9
 
22
- <%= form_with model: @declaration, url: hr_lite.tax_declaration_path, method: :patch,
23
- scope: :tax_declaration, local: true do |f| %>
10
+ <%= form_with model: @declaration, url: hr_lite.tax_declaration_path, method: :patch, scope: :tax_declaration, local: true,
11
+ multipart: true, data: { hrl_tax_preview: hr_lite.preview_tax_declaration_path } do |f| %>
24
12
  <%= render "hr_lite/shared/form_errors", record: @declaration %>
25
13
 
26
14
  <section class="hrl-card">
27
- <div class="hrl-field">
28
- <%= f.label :regime, "Tax regime", id: "hrl-tax-regime", for: nil %>
29
- <div role="radiogroup" aria-labelledby="hrl-tax-regime">
30
- <% [ [ "new", "New regime", "Lower rates, no deductions." ],
31
- [ "old", "Old regime", "The deductions below reduce your tax." ] ].each do |value, name, hint| %>
32
- <div class="hrl-field--check">
33
- <%= f.radio_button :regime, value %>
34
- <%= f.label :regime, value: value do %><span><%= name %><span class="hrl-hint"><%= hint %></span></span><% end %>
35
- </div>
36
- <% end %>
37
- </div>
38
- </div>
39
- </section>
15
+ <p>
16
+ Declaration: <%= hrl_request_status_badge(@declaration.status == "verified" ? "approved" : @declaration.status) %>
17
+ <% if @declaration.note.present? %><span class="hrl-small hrl-muted">— <%= @declaration.note %></span><% end %>
18
+ </p>
19
+ <fieldset class="hrl-field" <%= "disabled" if locked %>>
20
+ <legend>Tax regime</legend>
21
+ <% [ [ "new", "New regime", "Lower rates; only the standard deduction." ],
22
+ [ "old", "Old regime", "Higher rates; the investments below reduce your tax." ] ].each do |value, name, hint| %>
23
+ <div class="hrl-field--check">
24
+ <%= f.radio_button :regime, value %>
25
+ <%= f.label :regime, value: value do %><span><%= name %><span class="hrl-hint"><%= hint %></span></span><% end %>
26
+ </div>
27
+ <% end %>
28
+ </fieldset>
29
+ </details>
40
30
 
41
31
  <details class="hrl-card" <%= "open" if @declaration.regime != "new" || @declaration.errors.any? %>>
42
- <summary class="hrl-btn">Deductions (old regime only)</summary>
43
- <%= f.fields_for :tax_declaration_items do |item| %>
44
- <div class="hrl-field">
45
- <%= item.hidden_field :section %>
46
- <%= item.label :declared_amount, item.object.section_label %>
47
- <%= item.text_field :declared_amount,
48
- value: item.object.declared_amount&.to_s("F"), inputmode: "decimal" %>
49
- <%= item.label :label, "What it is (optional)", class: "hrl-mt hrl-muted" %>
50
- <%= item.text_field :label %>
51
- <% if item.object.persisted? && item.object.verified_amount %>
52
- <span class="hrl-small hrl-muted">
53
- HR verified <%= hrl_money(item.object.verified_amount) %>
54
- </span>
55
- <% end %>
56
- </div>
57
- <% end %>
58
- <p>Total claimed: <strong><%= hrl_money(@declaration.declared_total) %></strong></p>
32
+ <summary class="hrl-btn">Investments and rent (old regime only)</summary>
33
+ <p class="hrl-small hrl-muted">Enter what you will invest or pay this year and attach the proof. The legal limit is applied for you — anything above it simply does not count. Until HR checks the proof, what you claim is used, so you never overpay tax in the meantime.</p>
34
+ <fieldset <%= "disabled" if locked %>>
35
+ <%= f.fields_for :tax_declaration_items do |item| %>
36
+ <% line = item.object %>
37
+ <div class="hrl-field">
38
+ <%= item.hidden_field :section %>
39
+ <%= item.label :declared_amount, line.section_label %>
40
+ <%= item.text_field :declared_amount, value: line.declared_amount&.to_s("F"), inputmode: "decimal", autocomplete: "off" %>
41
+ <% if line.cap %>
42
+ <progress class="hrl-tax-cap" max="<%= line.cap %>" value="<%= [ line.declared_amount.to_i, line.cap ].min %>" aria-hidden="true"></progress>
43
+ <span class="hrl-hint" data-hrl-cap="<%= line.cap %>"><%= hrl_rupees([ line.declared_amount.to_i, line.cap ].min) %> of the <%= hrl_rupees(line.cap) %> limit counts</span>
44
+ <% end %>
45
+ <% if line.section.start_with?("80d") %>
46
+ <div class="hrl-field--check hrl-mt"><%= item.check_box :senior %><%= item.label :senior, line.section == "80d" ? "I am 60 or older" : "A parent is 60 or older" %></div>
47
+ <% end %>
48
+ <% if line.section == "hra" %>
49
+ <%= item.label :landlord_pan, "Landlord's PAN (needed above ₹1,00,000 a year)", class: "hrl-mt" %>
50
+ <%= item.text_field :landlord_pan, autocomplete: "off", maxlength: 10, autocapitalize: "characters" %>
51
+ <% end %>
52
+ <%= item.label :proofs, "Proof (PDF, JPG or PNG, up to 5 MB each)", class: "hrl-mt" %>
53
+ <%= item.file_field :proofs, multiple: true, accept: "application/pdf,image/jpeg,image/png" %>
54
+ <% if line.persisted? && line.proofs.attached? %>
55
+ <ul class="hrl-row hrl-mt" aria-label="Attached proof">
56
+ <% line.proofs.each do |proof| %><li class="hrl-badge hrl-badge--muted"><%= proof.filename %></li><% end %>
57
+ </ul>
58
+ <% end %>
59
+ <% if line.persisted? && line.verified_amount %><span class="hrl-small hrl-muted">HR accepted <%= hrl_money(line.verified_amount) %></span><% end %>
60
+ </div>
61
+ <% end %>
62
+ </fieldset>
59
63
  </details>
60
64
 
61
- <% if submittable %>
62
- <p class="hrl-muted hrl-small">
63
- Submitting locks it for HR to check. They can send it back if the proof
64
- does not match.
65
- </p>
65
+ <% unless locked %>
66
+ <% if submittable %><p class="hrl-muted hrl-small">Submitting sends it to HR to check the proof. They can send it back if something does not match.</p><% end %>
67
+ <div class="hrl-form-actions">
68
+ <%= f.submit "Save", class: submittable ? "hrl-btn" : "hrl-btn hrl-btn--primary" %>
69
+ <% if submittable %><button type="submit" name="submit_to_hr" value="1" class="hrl-btn hrl-btn--primary">Submit to HR</button><% end %>
70
+ </div>
71
+ <% else %>
72
+ <p class="hrl-small hrl-muted">HR has verified this year's declaration. Ask HR to reopen it to change anything.</p>
66
73
  <% end %>
67
- <div class="hrl-form-actions">
68
- <%= f.submit "Save draft", class: submittable ? "hrl-btn" : "hrl-btn hrl-btn--primary" %>
69
- <% if submittable %>
70
- <button type="submit" name="submit_to_hr" value="1" class="hrl-btn hrl-btn--primary">Submit to HR</button>
71
- <% end %>
72
- </div>
73
74
  <% end %>
75
+ <% content_for(:scripts) { javascript_include_tag "hr_lite/tax_preview", defer: true } %>
@@ -31,6 +31,7 @@
31
31
  <section class="hrl-card hrl-card--private" aria-labelledby="private-records">
32
32
  <h2 class="hrl-card__title" id="private-records">Private records</h2>
33
33
  <p class="hrl-small"><strong>Private</strong> — only <%= @person.id == hr_current_user.id ? "you" : name %> and people with access can see this.</p>
34
+ <% if @show_balance %><%= render "hr_lite/leave_requests/balance_chips", user: @person, compact: true, bare: true %><% end %>
34
35
  <%= render "events", events: @private_events, empty: "Nothing private in #{@year}." %>
35
36
  </section>
36
37
  <% end %>
data/config/routes.rb CHANGED
@@ -35,6 +35,7 @@ HrLite::Engine.routes.draw do
35
35
  end
36
36
  resource :tax_declaration, only: %i[show update], controller: "tax_declarations" do
37
37
  post :submit
38
+ post :preview
38
39
  end
39
40
  resources :loans, only: :index
40
41
  get "team", to: "team#show"
@@ -80,7 +81,8 @@ HrLite::Engine.routes.draw do
80
81
  end
81
82
  get "documents/for/:user_id", to: "documents#show", as: :employee_documents
82
83
  resources :tax_declarations, only: %i[index show update] do
83
- member { post :verify; post :reject }
84
+ member { post :verify; post :reject; post :accept_all; get "proofs/:proof_id", action: :proof, as: :proof }
85
+ collection { get :overview; get "people/:user_id", action: :person, as: :person }
84
86
  end
85
87
  resources :loans, only: %i[index new create show] do
86
88
  member { post :close; post :cancel }
@@ -120,10 +122,14 @@ HrLite::Engine.routes.draw do
120
122
  end
121
123
  resources :performance_plans, only: %i[index new create edit update]
122
124
  resources :awards, only: %i[index new create edit update]
125
+ resources :ca_reviews, only: :index do
126
+ collection { post :verify; post :flag; post :verify_all }
127
+ member { post :resolve }
128
+ end
123
129
  resources :payroll_runs, only: %i[index show new create destroy] do
124
130
  member do
125
131
  post :compute
126
- post :finalize
132
+ post :approve
127
133
  post :unlock
128
134
  post :publish
129
135
  get :register
@@ -0,0 +1,9 @@
1
+ class AddCtcToHrLiteSalaryStructures < ActiveRecord::Migration[8.1]
2
+ # The agreed Annual CTC is now the input (encrypted, like every amount);
3
+ # metro decides the HRA rate. Existing rows keep their lines and get a CTC
4
+ # the next time they are saved.
5
+ def change
6
+ add_column :hr_lite_salary_structures, :annual_ctc, :text
7
+ add_column :hr_lite_salary_structures, :metro, :boolean, null: false, default: false
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ class AddApprovalToHrLitePayrollRuns < ActiveRecord::Migration[8.1]
2
+ # Two people approve a run: the first is stamped here, the second
3
+ # finalizes and publishes it. auto_approved_at marks a run the 3rd-of-month
4
+ # job approved because nobody had.
5
+ def change
6
+ add_column :hr_lite_payroll_runs, :first_approved_by_id, :bigint
7
+ add_column :hr_lite_payroll_runs, :first_approved_at, :datetime
8
+ add_column :hr_lite_payroll_runs, :auto_approved_at, :datetime
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ class GrantHrLitePayrollApprove < ActiveRecord::Migration[8.1]
2
+ # payroll.approve is new in 0.24.0, and RoleSeeds never touches an existing
3
+ # role, so without this nobody on a running install could approve. HR and
4
+ # Super Admin approve; HR also reads every run, sets salary structures and
5
+ # edits employee profiles (owner decision 2026-10-09).
6
+ GRANTS = {
7
+ "HR" => { "payroll.approve" => "all", "payroll.view" => "all", "salary.view" => "all", "salary.manage" => "all", "profile.manage" => "all" },
8
+ "Super Admin" => { "payroll.approve" => "all" }
9
+ }.freeze
10
+
11
+ def up
12
+ GRANTS.each do |role_name, grants|
13
+ role = HrLite::Role.find_by(name: role_name) or next
14
+ grants.each do |key, scope|
15
+ grant = HrLite::RoleGrant.find_or_initialize_by(role: role, permission_key: key)
16
+ grant.update!(scope: scope)
17
+ end
18
+ end
19
+ end
20
+
21
+ def down; end
22
+ end
@@ -0,0 +1,16 @@
1
+ class SeedHrLiteFy2026RateCards < ActiveRecord::Migration[8.1]
2
+ # Ships the FY 2026-27 card and the October 2026 EPF-ceiling card into an
3
+ # install that already has its cards. Creates only what is missing — a card
4
+ # an accountant already entered is never touched.
5
+ def up
6
+ # A fresh install gets every shipped card from `hr_lite:seed`; this is for
7
+ # one that already seeded, where the seed would never run again.
8
+ return unless HrLite::StatutoryRateCardRecord.exists?
9
+
10
+ require "hr_lite/statutory_seeds"
11
+ created = HrLite::StatutorySeeds.seed_cards!
12
+ say "Seeded #{created.join(', ')}" if created.any?
13
+ end
14
+
15
+ def down; end
16
+ end
@@ -0,0 +1,9 @@
1
+ class AddTaxFieldsToHrLiteTaxDeclarationItems < ActiveRecord::Migration[8.1]
2
+ # senior lifts the 80D limit (₹25,000 → ₹50,000) for whoever the line covers;
3
+ # landlord_pan is required once rent passes ₹1 lakh a year (encrypted:
4
+ # it is somebody else's government ID). Proofs attach through Active Storage.
5
+ def change
6
+ add_column :hr_lite_tax_declaration_items, :senior, :boolean, null: false, default: false
7
+ add_column :hr_lite_tax_declaration_items, :landlord_pan, :text
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ class GrantHrLiteTaxToHr < ActiveRecord::Migration[8.1]
2
+ # HR sees and checks everyone's tax (owner decision 2026-10-09). RoleSeeds
3
+ # never touches an existing role, so a running install needs this.
4
+ def up
5
+ role = HrLite::Role.find_by(name: "HR") or return
6
+ { "tax.view" => "all", "tax.manage" => "all" }.each do |key, scope|
7
+ HrLite::RoleGrant.find_or_initialize_by(role: role, permission_key: key).update!(scope: scope)
8
+ end
9
+ end
10
+
11
+ def down; end
12
+ end
@@ -0,0 +1,21 @@
1
+ class CreateHrLiteCaReviews < ActiveRecord::Migration[8.1]
2
+ # The CA's year-end mark on one salary slip or tax declaration: verified,
3
+ # or flagged with what is wrong until HR/payroll resolves it.
4
+ def change
5
+ create_table :hr_lite_ca_reviews do |t|
6
+ t.string :subject_type, null: false
7
+ t.bigint :subject_id, null: false
8
+ # verified | flagged | resolved
9
+ t.string :status, null: false
10
+ t.text :note
11
+ t.bigint :reviewed_by_id, null: false
12
+ t.datetime :reviewed_at, null: false
13
+ t.bigint :resolved_by_id
14
+ t.datetime :resolved_at
15
+ t.text :resolution_note
16
+ t.timestamps
17
+ end
18
+ add_index :hr_lite_ca_reviews, %i[subject_type subject_id], unique: true
19
+ add_check_constraint :hr_lite_ca_reviews, "status IN ('verified', 'flagged', 'resolved')", name: "hr_lite_ca_reviews_status_check"
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ class SeedHrLiteCaRole < ActiveRecord::Migration[8.1]
2
+ # The CA role is new in 0.26.0 and payroll.verify is a new key. RoleSeeds
3
+ # creates the missing role; Super Admin, an existing role, gets the key here.
4
+ def up
5
+ return unless HrLite::Role.exists?
6
+
7
+ require "hr_lite/role_seeds"
8
+ HrLite::RoleSeeds.call
9
+ super_admin = HrLite::Role.find_by(name: "Super Admin") or return
10
+ HrLite::RoleGrant.find_or_initialize_by(role: super_admin, permission_key: "payroll.verify").update!(scope: "all")
11
+ end
12
+
13
+ def down; end
14
+ end
@@ -22,6 +22,11 @@ module HrLite
22
22
  "attendance.missed_check_out" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
23
23
  "attendance.short_day" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
24
24
  "payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
25
+ "ca.flagged" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
26
+ "ca.reminder" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
27
+ "ca.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
28
+ "payroll.approval_needed" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
29
+ "payroll.overdue" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
25
30
  "payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
26
31
  "kudos.mentioned" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
27
32
  "appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
@@ -37,6 +37,8 @@ module HrLite
37
37
  "payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
38
38
  "payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
39
39
  "payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
40
+ "payroll.verify" => [ "Payroll", "Verify or flag each salary slip and tax declaration at year end (CA review)" ],
41
+ "payroll.approve" => [ "Payroll", "Approve a computed payroll run (two approvals finalize and publish it)" ],
40
42
  "salary.view" => [ "Payroll", "See salary structures" ],
41
43
  "salary.manage" => [ "Payroll", "Set and revise salary structures" ],
42
44
  "appraisal.view" => [ "Growth", "See appraisals" ],
@@ -36,9 +36,9 @@ module HrLite
36
36
  grants: {
37
37
  "leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
38
38
  "leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
39
- "profile.view" => "all", "payroll.view" => "self",
40
- "appraisal.view" => "self", "resignation.view" => "all",
41
- "document.view" => "all", "tax.view" => "self",
39
+ "profile.view" => "all", "payroll.view" => "all", "payroll.approve" => "all",
40
+ "salary.view" => "all", "salary.manage" => "all", "profile.manage" => "all", "appraisal.view" => "self", "resignation.view" => "all",
41
+ "document.view" => "all", "tax.view" => "all", "tax.manage" => "all",
42
42
  "expense.claim" => "self", "benefit.view" => "all", "benefit.manage" => "all",
43
43
  "hr_request.raise" => "self", "hr_request.manage" => "all", "policy.view" => "all",
44
44
  "asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
@@ -70,6 +70,12 @@ module HrLite
70
70
  "asset.view" => "all", "asset.manage" => "all", "checklist.manage" => "all"
71
71
  }
72
72
  },
73
+ Role::CA => {
74
+ description: "Outside chartered accountant: reads every slip and tax declaration and verifies them before 31 March.",
75
+ grants: {
76
+ "payroll.view" => "all", "salary.view" => "all", "tax.view" => "all", "payroll.verify" => "all"
77
+ }
78
+ },
73
79
  Role::SUPER_ADMIN => {
74
80
  description: "Everything, including pay, appraisals and who holds which role.",
75
81
  grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }