hr_lite 0.23.1 → 0.25.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +2 -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/payroll_runs_controller.rb +14 -4
  7. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +6 -16
  8. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +46 -0
  9. data/app/controllers/hr_lite/tax_declarations_controller.rb +19 -1
  10. data/app/controllers/hr_lite/timelines_controller.rb +2 -1
  11. data/app/helpers/hr_lite/application_helper.rb +5 -0
  12. data/app/jobs/hr_lite/payroll_auto_approve_job.rb +21 -0
  13. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +1 -1
  14. data/app/models/hr_lite/payroll_run.rb +47 -3
  15. data/app/models/hr_lite/salary_structure.rb +22 -8
  16. data/app/models/hr_lite/statutory_rate_card_record.rb +6 -6
  17. data/app/models/hr_lite/tax_declaration.rb +24 -0
  18. data/app/models/hr_lite/tax_declaration_item.rb +56 -4
  19. data/app/services/hr_lite/calculators/tds.rb +7 -6
  20. data/app/services/hr_lite/payroll_run_processor.rb +9 -0
  21. data/app/services/hr_lite/slip_builder.rb +1 -1
  22. data/app/services/hr_lite/tax_computation.rb +61 -0
  23. data/app/services/hr_lite/timeline.rb +10 -1
  24. data/app/views/hr_lite/admin/employees/show.html.erb +3 -1
  25. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +17 -7
  26. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +15 -7
  27. data/app/views/hr_lite/admin/statutory_rate_cards/_form.html.erb +1 -1
  28. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +1 -0
  29. data/app/views/hr_lite/admin/tax_declarations/overview.html.erb +39 -0
  30. data/app/views/hr_lite/admin/tax_declarations/person.html.erb +10 -0
  31. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +19 -2
  32. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +6 -4
  33. data/app/views/hr_lite/tax/_computation.html.erb +56 -0
  34. data/app/views/hr_lite/tax_declarations/show.html.erb +61 -59
  35. data/app/views/hr_lite/timelines/show.html.erb +1 -0
  36. data/config/routes.rb +4 -2
  37. data/db/migrate/20261010100000_add_ctc_to_hr_lite_salary_structures.rb +9 -0
  38. data/db/migrate/20261010100100_add_approval_to_hr_lite_payroll_runs.rb +10 -0
  39. data/db/migrate/20261010100200_grant_hr_lite_payroll_approve.rb +22 -0
  40. data/db/migrate/20261010100300_seed_hr_lite_fy2026_rate_cards.rb +16 -0
  41. data/db/migrate/20261010100400_add_tax_fields_to_hr_lite_tax_declaration_items.rb +9 -0
  42. data/db/migrate/20261010100500_grant_hr_lite_tax_to_hr.rb +12 -0
  43. data/lib/hr_lite/notifications.rb +2 -0
  44. data/lib/hr_lite/permissions.rb +1 -0
  45. data/lib/hr_lite/role_seeds.rb +3 -3
  46. data/lib/hr_lite/statutory_rate_card.rb +65 -43
  47. data/lib/hr_lite/statutory_seeds.rb +4 -2
  48. data/lib/hr_lite/version.rb +1 -1
  49. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f73917345c95226e4a223cbe4e3e13b4819e4ca6ea3831cbc4c129eff64bbe1
4
- data.tar.gz: '03581e4aa969660467a7b7ecfd04938677398dbf57fe49b43beaee3b82c635e9'
3
+ metadata.gz: 3323d772472049e0607345bdd5443230a8976239aae71e4ebc2bb1dcb414380f
4
+ data.tar.gz: 1f83df9c391642235c7da41b913757564b2ad9d60069c52ff06a6143fef18d06
5
5
  SHA512:
6
- metadata.gz: ce7315e67fa6cafd5beb9cba391e05545281e3d4af17bf1ef27576cbccef1195e0b6788f80cc9c702de68bbed0e1e9a1d754bb175f1557d8023932c22073cb66
7
- data.tar.gz: 73934a9be7d733498691e8f35cf6705501992a28163040133165e599287793e0810ca194f6b0e744ab23f6dee9c4742f3a957e1a30efb4672dc3a9451e5828e8
6
+ metadata.gz: d64948ae1e08cdaed32254dcc70541c98cf7ed4daa0c5726b0977c1eaf615953bc267313df369fc7fc53a0f38df595f52c6f584df8b1ddd98a7bd9ecf201e61f
7
+ data.tar.gz: ff03f9c30cad45019ccc7404d5fffffcaf0240b63b9028ee8ddcaff4f5dfd1e0030906b506a07cd716afd2f70e44e13ef296626691478d36bbbab4b316a8a813
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] - 2026-10-10
11
+
12
+ Three migrations: `senior` and `landlord_pan` (encrypted) on declaration lines; a data migration adding the FY 2026-27 and October 2026 rate cards to installs that already have cards; a data migration giving HR `tax.view` and `tax.manage` (all).
13
+
14
+ ### Added
15
+ - Statutory cards ship for FY 2026-27 (Finance Act 2026 left slabs, standard deduction and the §87A rebate unchanged; the Income-tax Act 2025 is in force) and from 1 October 2026, when the EPF/EPS/EDLI wage ceiling rose to ₹25,000 (Notification S.O. 5109(E), 17 Sep 2026; applied from October payroll, so September stays on ₹15,000). Both are seeded already verified, with the source in the card's notes. Cards may now take effect on the 1st of any month for a change notified mid-year. `StatutoryRateCard::SOURCES`.
16
+ - `HrLite::TaxComputation`: a person's income tax worked out now — the structure in force, the slips already paid and the declaration — for both regimes, using payroll's own TDS calculator. Each step is shown: salary paid and expected, HRA exemption, standard deduction, every deduction with what was claimed, what counts and the limit, taxable income, each slab, tax with rebate and cess, TDS deducted and each remaining month. `Calculators::Tds.slab_rows`.
17
+ - The legal limits apply by themselves (old regime): Sec 123 / 80C ₹1,50,000, Sec 124 / 80CCD(1B) ₹50,000, home-loan interest ₹2,00,000, Sec 126 / 80D ₹25,000 or ₹50,000 for a senior — now with a separate line for parents. HRA is the least of the HRA received, rent less 10% of Basic and 50% (metro) / 40% of Basic. A landlord's PAN is required above ₹1,00,000 of rent. Payroll's TDS uses the same capped figures.
18
+ - Proof on every declaration line: PDF, JPG or PNG up to 5 MB each. HR opens each file through an audited download (`tax_proof.downloaded`) and can "Accept all and verify" in one click.
19
+ - The employee's Tax page leads with this year's tax, TDS a month, deducted and still to deduct, the two regimes side by side with the cheaper one named, and the full working. It re-works the tax as you type (`POST /tax_declaration/preview`, `hr_lite/tax_preview.js`), nothing saved; each line's limit bar follows the amount.
20
+ - HR and admin: the full working on every declaration, both regimes; "Everyone's tax" (`/admin/tax_declarations/overview`) lists each person's regime, tax, TDS so far and a month, the cheaper regime, and declaration status; each name opens that person's working whether or not they filed. HR gets a "Tax checks" link under Manage.
21
+
22
+ ### Changed
23
+ - Section labels name the Income-tax Act 2025 section beside the old one ("Sec 123 (old 80C) — …").
24
+ - A claim above its legal limit no longer lowers TDS beyond the limit.
25
+
26
+ ## [0.24.0] - 2026-10-10
27
+
28
+ Three migrations: `annual_ctc` (encrypted) and `metro` on salary structures; `first_approved_by_id`, `first_approved_at` and `auto_approved_at` on payroll runs; and a data migration granting the new `payroll.approve` permission to HR and Super Admin, plus `payroll.view`, `salary.view`, `salary.manage` and `profile.manage` to HR. Schedule `HrLite::PayrollAutoApproveJob` on the 3rd.
29
+
30
+ ### Added
31
+ - Salary structures take the Annual CTC only. Saving splits it — Basic 50% of CTC (the labour codes' wage floor), HRA 50% of Basic for a metro (new "Works in a metro" box) and 40% elsewhere, employer PF, ESI and gratuity (4.81% of Basic) inside the CTC, special allowance the rest — and stores the CTC, encrypted. The form shows the monthly split read-only; Basic, HRA, special and other are no longer typed. Existing structures keep their numbers until next saved, and their CTC is unchanged (gratuity is counted only on a CTC-split structure). `SalaryStructure::GRATUITY_RATE`, `#gratuity`.
32
+ - Payroll needs two approvals: anyone with `payroll.approve` approves a reviewed run (Approve), and a second, different person — one of the two able to run payroll — approves it again, which finalizes and publishes it. Recomputing clears the first approval. The run page says who approved and what is left. Approvers without `payroll.manage` (HR) get a "Payroll approval" link under Manage, read every run and approve, but cannot compute, unlock or delete.
33
+ - `HrLite::PayrollAutoApproveJob`: on the 3rd, a run still in review is approved and published by the system (`auto_approved_at`, audit `payroll.auto_approved`), unless it has blocking warnings — no salary structure for someone, a state with no PT slabs, or a statutory rate card that is last year's or not yet verified — when approvers hear `payroll.overdue` instead.
34
+ - Payroll warns when leave or attendance fixes for the month are still pending: they are paid as loss of pay until approved.
35
+
36
+ ### Changed
37
+ - HR (role defaults and, on existing installs, the data migration) can now set salary structures, read and approve payroll, and edit employee profiles — which includes the screens gated on `profile.manage`: employees, resignations, awards, leave types, holidays, offices, company settings and the audit trail. Roles stay `role.manage`.
38
+ - Salary structures are gated on `salary.manage` (was `payroll.manage`), and the employee page shows them to `salary.view`.
39
+ - The single-person Finalize button and route are gone: a run is finalized by its second approval. Publish stays for a run unlocked and re-approved.
40
+ - `PayrollAutoDraftJob` notifies `payroll.approve` holders as well as `payroll.manage`.
41
+
42
+ ## [0.23.2] - 2026-10-10
43
+
44
+ No migrations.
45
+
46
+ ### Added
47
+ - Timeline "Private records" card: a Leave balance line per leave type for the current leave year ("Casual leave · 7 of 10 left"), and pending or rejected leave requests ("Casual leave — pending"), for the employee and anyone `leave.view` reaches (their manager, HR). `leave_requests/_balance_chips` takes optional `user:` and `bare:` locals.
48
+
10
49
  ## [0.23.1] - 2026-10-10
11
50
 
12
51
  No migrations.
data/README.md CHANGED
@@ -381,7 +381,8 @@ whatever you run). Each is idempotent and sends nothing on a quiet day.
381
381
  | Job | Schedule | Purpose |
382
382
  |---|---|---|
383
383
  | `HrLite::DailyDigestJob` | each morning | Leadership digest: who is out today, pending approvals, flagged punches, missing checkouts |
384
- | `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify leadership for review (publishing stays a human action) |
384
+ | `HrLite::PayrollAutoDraftJob` | monthly, on the 1st | Draft and compute the previous month's payroll from attendance, then notify the approvers (`payroll.approve`) and payroll operators |
385
+ | `HrLite::PayrollAutoApproveJob` | monthly, on the 3rd | Approve and publish last month's run if nobody finished approving it — unless it has blocking warnings (no salary structure, no PT slabs, a stale or unverified statutory card), when the approvers are told it is overdue instead |
385
386
  | `HrLite::LeaveYearRolloverJob` | leave year's first day (Jan 1, or Jul 1 for a July–June year) | Materialize carry-forward into the new year's balances |
386
387
  | `HrLite::AttendanceCloseJob` | each night, `55 23 * * * Asia/Kolkata` | Close the day: an open punch is checked out at 23:59 and marked half day; a run before noon (a retry, or a morning schedule that lets night shifts check out first) closes the previous day instead; nobody checked in stays absent. Each is emailed how to fix it. Skips weekends, holidays and approved leave. Run it once a day — a re-run tells the no-shows again. |
387
388
 
@@ -0,0 +1,38 @@
1
+ // Live tax: a form with data-hrl-tax-preview="<url>" posts what is on screen
2
+ // (files left out) as it is typed, and the server's worked-out tax replaces
3
+ // #hrl-tax-preview. Nothing is saved; the Save button still does that.
4
+ (function () {
5
+ "use strict";
6
+ var form = document.querySelector("form[data-hrl-tax-preview]");
7
+ var panel = document.getElementById("hrl-tax-preview");
8
+ if (!form || !panel) return;
9
+
10
+ var timer;
11
+ function refresh() {
12
+ var data = new FormData(form);
13
+ Array.from(data.keys()).forEach(function (key) { if (/\[proofs\]/.test(key)) data.delete(key); });
14
+ data.delete("_method");
15
+ fetch(form.dataset.hrlTaxPreview, { method: "POST", body: data, credentials: "same-origin", headers: { "Accept": "text/html" } })
16
+ .then(function (response) { return response.ok ? response.text() : null; })
17
+ .then(function (html) { if (html) panel.innerHTML = html; });
18
+ }
19
+ var rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
20
+ // The limit bar and "₹X of the ₹Y limit counts" follow the amount as typed.
21
+ function updateCap(input) {
22
+ var field = input.closest(".hrl-field");
23
+ var hint = field && field.querySelector("[data-hrl-cap]");
24
+ if (!hint) return;
25
+ var cap = Number(hint.dataset.hrlCap);
26
+ var counts = Math.min(Math.max(Number(input.value.replace(/[^0-9.]/g, "")) || 0, 0), cap);
27
+ field.querySelector("progress").value = counts;
28
+ hint.textContent = rupees.format(counts) + " of the " + rupees.format(cap) + " limit counts";
29
+ }
30
+ function schedule(event) {
31
+ if (event.target.type === "file") return;
32
+ if (/declared_amount/.test(event.target.name)) updateCap(event.target);
33
+ clearTimeout(timer);
34
+ timer = setTimeout(refresh, 400);
35
+ }
36
+ form.addEventListener("input", schedule);
37
+ form.addEventListener("change", schedule);
38
+ })();
@@ -318,7 +318,8 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
318
318
 
319
319
  /* Forms */
320
320
  .hrl-field { margin-bottom: .8rem; }
321
- .hrl-field label { display: block; font-size: var(--hrl-t-small); font-weight: 600; margin-bottom: .3rem; }
321
+ .hrl-field label, .hrl-field legend { display: block; font-size: var(--hrl-t-small); font-weight: 600; margin-bottom: .3rem; }
322
+ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
322
323
  .hrl-hint { color: var(--hrl-muted); font-size: var(--hrl-t-small); margin-top: .25rem; }
323
324
  /* Full-width text-ish controls only — checkboxes/radios/hidden must keep
324
325
  their natural size (a bare [type] selector out-ranked .hrl-field--check
@@ -390,6 +391,16 @@ summary.hrl-btn::-webkit-details-marker { display: none; } /* Safari before 18.4
390
391
  .hrl-card--private .hrl-timeline__item::before { background: var(--hrl-bg); border: 2px solid var(--hrl-field); }
391
392
  .hrl-card--private .hrl-badge--muted { background: var(--hrl-card); }
392
393
 
394
+ /* Tax: the two regimes side by side, then the working as a plain table. */
395
+ .hrl-tax-compare { display: grid; grid-template-columns: 1fr 1fr; gap: var(--hrl-s-3); margin: var(--hrl-s-4) 0 var(--hrl-s-2); }
396
+ .hrl-tax-compare__col { border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); padding: var(--hrl-s-3); min-width: 0; }
397
+ .hrl-tax-compare__col--chosen { border: 2px solid var(--hrl-accent); }
398
+ .hrl-tax-compare__amount { font-size: var(--hrl-t-title); font-weight: 700; margin-top: var(--hrl-s-1); overflow-wrap: anywhere; }
399
+ .hrl-tax-working { margin-top: var(--hrl-s-3); }
400
+ .hrl-tax-working__table td { vertical-align: top; overflow-wrap: anywhere; }
401
+ .hrl-tax-working__total td { font-weight: 700; border-top: 1px solid var(--hrl-line); }
402
+ .hrl-tax-cap { display: block; width: 100%; height: 8px; margin-top: var(--hrl-s-1); accent-color: var(--hrl-accent); }
403
+
393
404
  /* Mention autocomplete menu */
394
405
  .hrl-mention-wrap { position: relative; }
395
406
  .hrl-mention-menu {
@@ -1,6 +1,9 @@
1
1
  module HrLite
2
2
  module Admin
3
3
  class PayrollRunsController < SuperadminController
4
+ # Approvers (HR) read runs and approve them without running payroll.
5
+ skip_before_action :require_money_access!, only: %i[index show approve]
6
+ before_action :require_approver_or_money!, only: %i[index show approve]
4
7
  # The register carries every bank account: its own key, not just payroll.manage.
5
8
  before_action -> { hr_require_permission!("payroll.export", scope: :all) }, only: :register
6
9
  def index
@@ -41,8 +44,8 @@ module HrLite
41
44
  transition { |run| run.compute!(actor: hr_current_user) && "Computed — review the slips." }
42
45
  end
43
46
 
44
- def finalize
45
- transition { |run| run.finalize!(actor: hr_current_user) && "Run finalized." }
47
+ def approve
48
+ transition { |run| run.approve!(actor: hr_current_user) && (run.published? ? "Second approval — run finalized and published." : "Approved — one more approver publishes it.") }
46
49
  end
47
50
 
48
51
  def unlock
@@ -74,12 +77,19 @@ module HrLite
74
77
 
75
78
  private
76
79
 
80
+ def require_approver_or_money!
81
+ return if hr_can?("payroll.manage", scope: :all)
82
+
83
+ hr_require_permission!("payroll.approve", scope: :all)
84
+ end
85
+
77
86
  def transition
78
87
  run = PayrollRun.find(params[:id])
79
88
  notice = yield(run)
80
89
  redirect_to admin_payroll_run_path(run), notice: notice
81
- rescue ActiveRecord::RecordInvalid
82
- redirect_to admin_payroll_run_path(run), alert: "That step is not available from #{run.status}."
90
+ rescue ActiveRecord::RecordInvalid => e
91
+ alert = e.message.match?(/approver/) ? e.message.sub(/\AValidation failed: /, "").capitalize + "." : "That step is not available from #{run.status}."
92
+ redirect_to admin_payroll_run_path(run), alert: alert
83
93
  end
84
94
 
85
95
  def register_csv(run)
@@ -1,6 +1,9 @@
1
1
  module HrLite
2
2
  module Admin
3
+ # salary.manage, not payroll.manage: HR sets structures without running payroll.
3
4
  class SalaryStructuresController < SuperadminController
5
+ skip_before_action :require_money_access!
6
+ before_action -> { hr_require_permission!("salary.manage", scope: :all) }
4
7
  before_action :set_profile
5
8
 
6
9
  def new
@@ -12,9 +15,7 @@ module HrLite
12
15
  @structure = SalaryStructure.new(structure_params.merge(
13
16
  user_id: @profile.user_id, created_by_id: hr_current_user.id
14
17
  ))
15
- return fill_from_ctc(:new) if params[:fill_from_ctc]
16
-
17
- if @structure.save
18
+ if @structure.save(context: :ctc_form)
18
19
  redirect_to admin_employee_path(@profile), notice: "Salary structure saved."
19
20
  else
20
21
  render :new, status: :unprocessable_entity
@@ -28,9 +29,7 @@ module HrLite
28
29
  def update
29
30
  @structure = SalaryStructure.where(user_id: @profile.user_id).find(params[:id])
30
31
  @structure.assign_attributes(structure_params)
31
- return fill_from_ctc(:edit) if params[:fill_from_ctc]
32
-
33
- if @structure.save
32
+ if @structure.save(context: :ctc_form)
34
33
  redirect_to admin_employee_path(@profile), notice: "Salary structure updated."
35
34
  else
36
35
  render :edit, status: :unprocessable_entity
@@ -39,22 +38,13 @@ module HrLite
39
38
 
40
39
  private
41
40
 
42
- # Shows the split for a check; only the Save button writes it.
43
- def fill_from_ctc(view)
44
- @structure.fill_from_ctc
45
- return render(view, status: :unprocessable_entity) unless @structure.valid?
46
-
47
- flash.now[:notice] = "Filled from CTC — check the lines, then Save."
48
- render view
49
- end
50
-
51
41
  def set_profile
52
42
  @profile = EmployeeProfile.find(params[:employee_id])
53
43
  end
54
44
 
55
45
  def structure_params
56
46
  params.require(:salary_structure).permit(
57
- :annual_ctc, :effective_from, :basic, :hra, :special_allowance, :other_earnings,
47
+ :annual_ctc, :effective_from, :metro,
58
48
  :pf_applicable, :pf_on_full_basic, :esi_applicable, :pt_state, :notes
59
49
  )
60
50
  end
@@ -17,6 +17,51 @@ module HrLite
17
17
 
18
18
  def show
19
19
  @declaration = find_visible
20
+ @tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
21
+ end
22
+
23
+ # Everyone's tax this year, worked out now, whether or not they declared.
24
+ def overview
25
+ visible = hr_access.visible_user_ids("tax.view")
26
+ profiles = EmployeeProfile.active_for(Date.current).includes(:user).order(:employee_code)
27
+ profiles = profiles.where(user_id: visible) if visible
28
+ @profiles = paginate(profiles)
29
+ @declarations = TaxDeclaration.where(user_id: @profiles.map(&:user_id),
30
+ financial_year: FinancialYear.start_for(Date.current)).index_by(&:user_id)
31
+ end
32
+
33
+ # Anyone's full working, filed or not.
34
+ def person
35
+ @person = HrLite.user_klass.find(params[:user_id])
36
+ hr_require_reach!("tax.view", @person)
37
+ @tax = TaxComputation.new(user: @person)
38
+ end
39
+
40
+ # The proof, through a permission check and an audit row — never a bare blob link.
41
+ def proof
42
+ declaration = find_visible
43
+ file = declaration.tax_declaration_items.flat_map { |item| item.proofs.to_a }.find { |p| p.id == params[:proof_id].to_i }
44
+ raise ActiveRecord::RecordNotFound unless file
45
+
46
+ AuditLog.record!(action: "tax_proof.downloaded", subject: declaration, actor: hr_current_user,
47
+ changes: { "file" => file.filename.to_s, "owner" => HrLite.display_name(declaration.user) })
48
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(file, disposition: "attachment", only_path: true),
49
+ allow_other_host: false
50
+ end
51
+
52
+ # One click when the proof matches the claim: every unchecked line is
53
+ # accepted at what was claimed (the legal limit still applies), then verified.
54
+ def accept_all
55
+ declaration = find_manageable
56
+ TaxDeclaration.transaction do
57
+ declaration.tax_declaration_items.each do |item|
58
+ item.update!(verified_amount: item.declared_amount) if item.verified_amount.nil?
59
+ end
60
+ declaration.verify!(actor: hr_current_user, note: "Proof accepted")
61
+ end
62
+ redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
63
+ rescue ActiveRecord::RecordInvalid
64
+ redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be accepted."
20
65
  end
21
66
 
22
67
  def update
@@ -26,6 +71,7 @@ module HrLite
26
71
  if @declaration.update(verification_params)
27
72
  redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
28
73
  else
74
+ @tax = TaxComputation.new(user: @declaration.user, declaration: @declaration)
29
75
  render :show, status: :unprocessable_entity
30
76
  end
31
77
  end
@@ -8,6 +8,16 @@ module HrLite
8
8
  # A saved draft holds only its claims; the rest still need a line to fill.
9
9
  fill_missing_sections(@declaration)
10
10
  @regime = @declaration.regime
11
+ @tax = TaxComputation.new(user: hr_current_user, declaration: @declaration)
12
+ end
13
+
14
+ # The form posts here as it is typed in: the tax worked out on what is on
15
+ # screen, nothing saved.
16
+ def preview
17
+ declaration = current_declaration || TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year)
18
+ declaration.regime = declaration_params[:regime] if declaration_params[:regime]
19
+ declaration.tax_declaration_items_attributes = preview_items
20
+ render partial: "hr_lite/tax/computation", locals: { tax: TaxComputation.new(user: hr_current_user, declaration: declaration), both: false }
11
21
  end
12
22
 
13
23
  def update
@@ -32,6 +42,7 @@ module HrLite
32
42
  # Re-render needs the full set of lines back, or the form loses the
33
43
  # sections the person had not filled in yet.
34
44
  fill_missing_sections(@declaration)
45
+ @tax = TaxComputation.new(user: hr_current_user, declaration: @declaration)
35
46
  render :show, status: :unprocessable_entity
36
47
  end
37
48
  end
@@ -75,6 +86,13 @@ module HrLite
75
86
  end
76
87
  end
77
88
 
89
+ # Lines for a preview: amounts only, never files (a blanked line is marked
90
+ # for removal, which the computation skips). Already filtered by the
91
+ # permit in declaration_params, so plain hashes are safe here.
92
+ def preview_items
93
+ (declaration_params[:tax_declaration_items_attributes] || {}).to_unsafe_h.transform_values { |row| row.except("proofs") }
94
+ end
95
+
78
96
  def employee_regime
79
97
  EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
80
98
  end
@@ -87,7 +105,7 @@ module HrLite
87
105
  def declaration_params
88
106
  permitted = params.require(:tax_declaration).permit(
89
107
  :regime,
90
- tax_declaration_items_attributes: %i[id section label declared_amount _destroy]
108
+ tax_declaration_items_attributes: [ :id, :section, :label, :declared_amount, :senior, :landlord_pan, :_destroy, { proofs: [] } ]
91
109
  )
92
110
  items = permitted[:tax_declaration_items_attributes]
93
111
  return permitted if items.blank?
@@ -13,7 +13,8 @@ module HrLite
13
13
  @public_events = timeline.public_events
14
14
  @private_events = timeline.private_events
15
15
  # Shown only to someone who may see private rows at all: the person, or a reach-holder.
16
- @private_card = @person.id == hr_current_user.id || @private_events.any?
16
+ @show_balance = HrLite.reaches?(hr_current_user, "leave.view", @person)
17
+ @private_card = @person.id == hr_current_user.id || @show_balance || @private_events.any?
17
18
  end
18
19
  end
19
20
  end
@@ -38,6 +38,11 @@ module HrLite
38
38
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
39
39
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
40
40
  { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
41
+ # HR approves payroll without running it; Super Admin reaches it under Organisation.
42
+ { label: "Payroll approval", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs" ],
43
+ if: -> { hr_can?("payroll.approve", scope: :all) && !hr_superadmin? } },
44
+ { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
45
+ if: -> { hr_can?("tax.view", scope: :all) && !hr_superadmin? } },
41
46
  { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
42
47
  ].freeze
43
48
 
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ # The 3rd-of-month backstop (schedule it on the 3rd): last month's run that
3
+ # is still in review is approved and published by the system, unless it has
4
+ # blocking warnings — then the approvers are told it is overdue instead.
5
+ class PayrollAutoApproveJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(month: Date.current.prev_month.beginning_of_month)
9
+ run = PayrollRun.find_by(period_month: month, status: "review")
10
+ return unless run
11
+ return if run.auto_approve!
12
+
13
+ people = HrLite.users_holding("payroll.approve", scope: :all).to_a
14
+ Notifications.publish(
15
+ "payroll.overdue",
16
+ title: "Payroll #{run.label} is overdue — not auto-approved: #{run.blocking_warnings.join('; ')}",
17
+ path: "/admin/payroll_runs/#{run.id}", bell_to: people, email_to: people
18
+ )
19
+ end
20
+ end
21
+ end
@@ -16,7 +16,7 @@ module HrLite
16
16
  return unless run.draft? && run.salary_slips.none?
17
17
 
18
18
  run.compute!(actor: nil)
19
- payroll = HrLite.users_holding("payroll.manage", scope: :all).to_a
19
+ payroll = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
20
20
  Notifications.publish(
21
21
  "payroll.draft_ready",
22
22
  title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
@@ -9,6 +9,11 @@ module HrLite
9
9
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
10
  belongs_to :finalized_by, class_name: HrLite.config.user_class, optional: true
11
11
  belongs_to :published_by, class_name: HrLite.config.user_class, optional: true
12
+ belongs_to :first_approved_by, class_name: HrLite.config.user_class, optional: true
13
+
14
+ # A run with one of these is not paid on the 3rd without a person looking:
15
+ # someone missing, a state levying ₹0, or last year's / unverified rates.
16
+ BLOCKING_WARNING = /\ANo (salary structure|professional-tax slabs)|statutory card/
12
17
 
13
18
  validates :period_month, presence: true, uniqueness: true
14
19
  validates :status, inclusion: { in: STATUSES }
@@ -40,7 +45,8 @@ module HrLite
40
45
  update!(status: "processing")
41
46
  PayrollRunProcessor.call(self)
42
47
  transaction do
43
- update!(status: "review", processed_at: Time.current)
48
+ # New numbers: an approval of the old ones no longer counts.
49
+ update!(status: "review", processed_at: Time.current, first_approved_by_id: nil, first_approved_at: nil)
44
50
  audit!("payroll.computed", actor,
45
51
  "slips" => salary_slips.count, "warnings" => warnings.length,
46
52
  "from_status" => previous)
@@ -60,7 +66,7 @@ module HrLite
60
66
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
61
67
 
62
68
  transaction do
63
- update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
69
+ update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor&.id)
64
70
  # Finalizing freezes every slip in the run. Who did it, to how many
65
71
  # people, is the row an investigation starts from.
66
72
  audit!("payroll.finalized", actor, "slips" => salary_slips.count)
@@ -74,6 +80,34 @@ module HrLite
74
80
  true
75
81
  end
76
82
 
83
+ # Two people approve a reviewed run, at least one of them able to run
84
+ # payroll; the second approval finalizes and publishes it.
85
+ def approve!(actor:)
86
+ raise_unless %w[review]
87
+ unless HrLite.can?(actor, "payroll.approve", scope: :all)
88
+ raise ActiveRecord::RecordInvalid.new(self), "not an approver"
89
+ end
90
+ return stamp_first_approval!(actor) if first_approved_by_id.nil?
91
+ if first_approved_by_id == actor.id || [ first_approved_by, actor ].none? { |u| HrLite.can?(u, "payroll.manage", scope: :all) }
92
+ raise ActiveRecord::RecordInvalid.new(self), "needs a second, different approver"
93
+ end
94
+
95
+ finalize!(actor: actor) && publish!(actor: actor)
96
+ end
97
+
98
+ def blocking_warnings = warnings.grep(BLOCKING_WARNING)
99
+
100
+ # The 3rd-of-month backstop: a clean run nobody finished approving is
101
+ # approved by the system and says so; a run with blocking warnings waits.
102
+ def auto_approve!
103
+ raise_unless %w[review]
104
+ return false if blocking_warnings.any?
105
+
106
+ update!(auto_approved_at: Time.current)
107
+ audit!("payroll.auto_approved", nil, "slips" => salary_slips.count)
108
+ finalize!(actor: nil) && publish!(actor: nil)
109
+ end
110
+
77
111
  def unlock!(actor:)
78
112
  raise_unless %w[finalized]
79
113
  transaction do
@@ -92,7 +126,7 @@ module HrLite
92
126
  def publish!(actor:)
93
127
  raise_unless %w[finalized]
94
128
  transaction do
95
- update!(status: "published", published_at: Time.current, published_by_id: actor.id)
129
+ update!(status: "published", published_at: Time.current, published_by_id: actor&.id)
96
130
  audit!("payroll.published", actor, "slips" => salary_slips.count)
97
131
  end
98
132
 
@@ -145,6 +179,16 @@ module HrLite
145
179
 
146
180
  private
147
181
 
182
+ def stamp_first_approval!(actor)
183
+ update!(first_approved_by_id: actor.id, first_approved_at: Time.current)
184
+ audit!("payroll.approved", actor, "slips" => salary_slips.count, "step" => 1)
185
+ others = HrLite.users_holding("payroll.approve", scope: :all).reject { |u| u.id == actor.id }
186
+ Notifications.publish("payroll.approval_needed",
187
+ title: "Payroll #{label}: #{HrLite.display_name(actor)} approved — one more approval publishes it",
188
+ path: "/admin/payroll_runs/#{id}", bell_to: others, email_to: others)
189
+ true
190
+ end
191
+
148
192
  # A repayment is booked when the run is FINALIZED, never at compute: a
149
193
  # draft is recomputed as often as the operator likes, and each pass would
150
194
  # otherwise take another instalment off the loan.
@@ -10,9 +10,15 @@ module HrLite
10
10
  belongs_to :user, class_name: HrLite.config.user_class
11
11
  belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
12
 
13
- encrypted_money :basic, :hra, :special_allowance, :other_earnings
14
- # Typed on the form to fill the lines; CTC is always derived, never stored.
15
- attribute :annual_ctc, :decimal
13
+ encrypted_money :basic, :hra, :special_allowance, :other_earnings, :annual_ctc
14
+
15
+ # Gratuity accrues at 15/26 days of Basic a year (4.81%), inside the CTC.
16
+ GRATUITY_RATE = BigDecimal("0.0481")
17
+
18
+ # The CTC is the input: saving one splits it, so no line is typed by hand.
19
+ before_validation :fill_from_ctc, if: -> { annual_ctc.present? }
20
+ # The admin form saves in this context: people only ever type the CTC.
21
+ validates :annual_ctc, presence: true, on: :ctc_form
16
22
 
17
23
  validates :effective_from, presence: true, uniqueness: { scope: :user_id }
18
24
  validates :basic, presence: true
@@ -49,7 +55,7 @@ module HrLite
49
55
  earnings, deductions, employer = [
50
56
  { "Basic" => basic, "HRA" => hra, "Special allowance" => special_allowance, "Other" => other_earnings },
51
57
  { "PF" => pf&.employee, "ESI" => esi.employee, "Professional tax" => pts.first },
52
- { "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer }
58
+ { "PF" => pf && (pf.employer_eps + pf.employer_epf), "ESI" => esi.employer, "Gratuity" => gratuity }
53
59
  ].map { |rows| rows.select { |_, amount| Money.d(amount).positive? } }
54
60
  monthly_ctc = gross + employer.values.sum(BigDecimal(0))
55
61
  in_hand = gross - deductions.values.sum(BigDecimal(0))
@@ -76,14 +82,22 @@ module HrLite
76
82
  (earlier || self).monthly_gross
77
83
  end
78
84
 
79
- # Owner's split of annual_ctc: Basic 50% of the monthly CTC, HRA 40% of
80
- # Basic, employer PF and ESI paid out of the CTC; Special takes the rest.
85
+ # Only a CTC-split structure carries gratuity in its CTC; older hand-typed
86
+ # ones never did, and their CTC must not move.
87
+ def gratuity
88
+ Money.round_rupee(Money.d(basic) * GRATUITY_RATE) if annual_ctc.present? && basic
89
+ end
90
+
91
+ # Owner's split of annual_ctc (the labour codes' 50% wage floor): Basic 50%
92
+ # of the monthly CTC, HRA 50% of Basic in a metro and 40% elsewhere,
93
+ # employer PF, ESI and gratuity paid out of the CTC; Special takes the rest.
81
94
  def fill_from_ctc(on: effective_from || Date.current)
82
95
  monthly_ctc = Money.round_rupee(Money.d(annual_ctc) / 12)
83
96
  self.basic = Money.round_rupee(monthly_ctc / 2)
84
- self.hra = Money.round_rupee(basic * BigDecimal("0.4"))
97
+ self.hra = Money.round_rupee(basic * BigDecimal(metro ? "0.5" : "0.4"))
98
+ self.other_earnings = nil
85
99
  self.special_allowance = 0
86
- rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF"))
100
+ rest = monthly_ctc - Money.d(breakup(on: on).dig(:employer, "PF")) - Money.d(gratuity)
87
101
  # Employer ESI is a share of the gross beside it: gross + ESI = rest.
88
102
  gross = (rest / (1 + StatutoryRateCard.for(on.beginning_of_month)[:esi][:employer_rate])).floor
89
103
  self.special_allowance = gross - basic - hra - Money.d(other_earnings)
@@ -53,14 +53,14 @@ module HrLite
53
53
  hash.to_h { |key, value| [ key, Money.d(value) ] }
54
54
  end
55
55
 
56
- # A card dated mid-year would mean two sets of slabs inside one financial
57
- # year, and the TDS projector works on an annual figure — there is no
58
- # sensible answer to "which slabs applied to this year's income" then.
56
+ # Cards take effect on the 1st of a month. Most open a financial year on
57
+ # 1 April; a mid-year card exists for a statutory change notified inside a
58
+ # year (the EPF wage ceiling, 17 Sep 2026) and repeats that year's tax
59
+ # slabs, because TDS projects one annual figure.
59
60
  def effective_from_opens_a_financial_year
60
- return if effective_from.blank?
61
- return if effective_from == FinancialYear.start_for(effective_from)
61
+ return if effective_from.blank? || effective_from.day == 1
62
62
 
63
- errors.add(:effective_from, "must be 1 April — a card covers a whole financial year")
63
+ errors.add(:effective_from, "must be the 1st of a month (1 April for a new financial year)")
64
64
  end
65
65
 
66
66
  REQUIRED = {
@@ -47,6 +47,30 @@ module HrLite
47
47
  tax_declaration_items.sum(BigDecimal(0)) { |i| i.verified_amount || BigDecimal(0) }
48
48
  end
49
49
 
50
+ Deduction = Struct.new(:section, :label, :claimed, :allowed, :cap, keyword_init: true)
51
+
52
+ # Old-regime lines with the law applied: each section's limit, and HRA
53
+ # worked out from the rent against the salary structure — never typed.
54
+ def deduction_rows(structure:)
55
+ tax_declaration_items.reject { |item| item.declared_amount.nil? || item.marked_for_destruction? }.map do |item|
56
+ allowed = item.section == "hra" ? self.class.hra_exemption(item.allowed(verified: verified?), structure) : item.allowed(verified: verified?)
57
+ Deduction.new(section: item.section, label: item.section_label, claimed: Money.d(item.declared_amount),
58
+ allowed: allowed, cap: item.cap)
59
+ end
60
+ end
61
+
62
+ def old_regime_deductions(structure:) = deduction_rows(structure: structure).sum(BigDecimal(0), &:allowed)
63
+
64
+ # Annual HRA exemption: the least of the HRA received, rent less 10% of
65
+ # Basic, and 50% of Basic in a metro (40% elsewhere).
66
+ def self.hra_exemption(rent, structure)
67
+ return BigDecimal(0) if structure.nil? || Money.d(rent).zero?
68
+
69
+ basic = Money.d(structure.basic) * 12
70
+ [ Money.d(structure.hra) * 12, Money.d(rent) - basic / 10, basic * BigDecimal(structure.metro ? "0.5" : "0.4") ]
71
+ .min.then { |amount| Money.round_rupee([ amount, BigDecimal(0) ].max) }
72
+ end
73
+
50
74
  def submit!(actor:)
51
75
  raise ActiveRecord::RecordInvalid.new(self), "not a draft" unless draft? || rejected?
52
76