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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f73917345c95226e4a223cbe4e3e13b4819e4ca6ea3831cbc4c129eff64bbe1
4
- data.tar.gz: '03581e4aa969660467a7b7ecfd04938677398dbf57fe49b43beaee3b82c635e9'
3
+ metadata.gz: 54c475874de00a0db443998c37e4caeb3340e202584e094db29ef423c4902698
4
+ data.tar.gz: 849526338af6b16a5648a5163d140f0cda40b2fa3c34ef2a63a3d2f454761132
5
5
  SHA512:
6
- metadata.gz: ce7315e67fa6cafd5beb9cba391e05545281e3d4af17bf1ef27576cbccef1195e0b6788f80cc9c702de68bbed0e1e9a1d754bb175f1557d8023932c22073cb66
7
- data.tar.gz: 73934a9be7d733498691e8f35cf6705501992a28163040133165e599287793e0810ca194f6b0e744ab23f6dee9c4742f3a957e1a30efb4672dc3a9451e5828e8
6
+ metadata.gz: 38bf1cd87f1e283d7e8a54e60a18a19020fae7ff52feca24941f94dc40673c29d3a08a99d8c82699353f9958a99ffaf39ab8ae0a6c2e5e237ee1d96a567ae0c7
7
+ data.tar.gz: 390843351f21b51a9f2f17f1159214035349ecf8a689722d8dd429b803775715b322d6914fc251bb1808ad03c09c569f55beb0c85b634de8fefa41069b6317f1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.26.0] - 2026-10-10
11
+
12
+ Two migrations: `hr_lite_ca_reviews`, and a data migration creating the CA role and giving Super Admin the new `payroll.verify` permission. Schedule `CaReviewReminderJob` weekly and `CaReviewOverdueJob` on 31 March.
13
+
14
+ ### Added
15
+ - CA year-end review: a new **CA** role for an outside chartered accountant (reads every slip, salary structure and tax declaration with its proofs; verifies; nothing else). CA logins without an HR profile are not staff — `HrLite.employees` leaves them out, so attendance, payroll, team screens and broadcasts never count them.
16
+ - "CA review" (`/admin/ca_reviews`): progress for the financial year (slips and declarations verified, flags open), tabs for salary slips and tax declarations, filters (to review, flagged, verified, resolved), Verify, Flag with a required note, and "Verify all shown". A flag tells approvers and payroll (`ca.flagged`); they fix it the normal way and Mark resolved with a note. A published slip is never edited. `HrLite::CaReview`.
17
+ - `CaReviewReminderJob` (weekly, acts only in March) and `CaReviewOverdueJob` (31 March): what is left, to the CA reviewers and then to payroll and HR.
18
+ - A CA (`payroll.view`, all) can open any slip read-only.
19
+
20
+ ## [0.25.0] - 2026-10-10
21
+
22
+ 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).
23
+
24
+ ### Added
25
+ - 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`.
26
+ - `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`.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+
32
+ ### Changed
33
+ - Section labels name the Income-tax Act 2025 section beside the old one ("Sec 123 (old 80C) — …").
34
+ - A claim above its legal limit no longer lowers TDS beyond the limit.
35
+
36
+ ## [0.24.0] - 2026-10-10
37
+
38
+ 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.
39
+
40
+ ### Added
41
+ - 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`.
42
+ - 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.
43
+ - `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.
44
+ - Payroll warns when leave or attendance fixes for the month are still pending: they are paid as loss of pay until approved.
45
+
46
+ ### Changed
47
+ - 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`.
48
+ - Salary structures are gated on `salary.manage` (was `payroll.manage`), and the employee page shows them to `salary.view`.
49
+ - 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.
50
+ - `PayrollAutoDraftJob` notifies `payroll.approve` holders as well as `payroll.manage`.
51
+
52
+ ## [0.23.2] - 2026-10-10
53
+
54
+ No migrations.
55
+
56
+ ### Added
57
+ - 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.
58
+
10
59
  ## [0.23.1] - 2026-10-10
11
60
 
12
61
  No migrations.
data/README.md CHANGED
@@ -381,7 +381,10 @@ 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::CaReviewReminderJob` | weekly (it acts only in March) | Tell CA reviewers how many slips and declarations are still unverified before 31 March |
386
+ | `HrLite::CaReviewOverdueJob` | 31 March, evening | Tell payroll and HR what the CA has not verified, and any flags left open |
387
+ | `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
388
  | `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
389
  | `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
390
 
@@ -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 {
@@ -0,0 +1,76 @@
1
+ module HrLite
2
+ module Admin
3
+ # The CA's year-end pass over every slip and declaration. Reviewers
4
+ # (payroll.verify) verify or flag; approvers and payroll resolve flags.
5
+ class CaReviewsController < BaseController
6
+ skip_before_action :require_operations_access!
7
+ before_action -> { require_any!("payroll.verify", "payroll.approve", "payroll.manage") }, only: :index
8
+ before_action -> { require_any!("payroll.verify") }, only: %i[verify flag verify_all]
9
+ before_action -> { require_any!("payroll.approve", "payroll.manage") }, only: :resolve
10
+
11
+ def index
12
+ @fy = FinancialYear.start_for(params[:fy].present? ? parse_date_param(params[:fy]) : Date.current)
13
+ @tab = params[:tab] == "declarations" ? "declarations" : "slips"
14
+ @status = params[:status].presence_in(%w[unreviewed verified flagged resolved])
15
+ scope = @tab == "slips" ? CaReview.slips_for(@fy).includes(:user, :payroll_run).order("hr_lite_payroll_runs.period_month", :user_id) : CaReview.declarations_for(@fy).includes(:user).order(:user_id)
16
+ type = scope.klass.name
17
+ reviewed = CaReview.where(subject_type: type)
18
+ scope = if @status == "unreviewed"
19
+ scope.where.not(id: reviewed.select(:subject_id))
20
+ elsif @status
21
+ scope.where(id: reviewed.where(status: @status).select(:subject_id))
22
+ else
23
+ scope
24
+ end
25
+ @rows = paginate(scope)
26
+ @reviews = reviewed.where(subject_id: @rows.map(&:id)).index_by(&:subject_id)
27
+ @left = CaReview.outstanding(@fy)
28
+ @totals = { "slips" => CaReview.slips_for(@fy).count, "declarations" => CaReview.declarations_for(@fy).count }
29
+ end
30
+
31
+ def verify
32
+ CaReview.verify!(subject, actor: hr_current_user)
33
+ redirect_back_to_list notice: "Verified."
34
+ end
35
+
36
+ def flag
37
+ return redirect_back_to_list(alert: "Say what is wrong — HR has to know what to fix.") if params[:note].blank?
38
+
39
+ CaReview.flag!(subject, actor: hr_current_user, note: params[:note])
40
+ redirect_back_to_list notice: "Flagged — HR and payroll have been told."
41
+ end
42
+
43
+ # "Verify all shown": the unreviewed rows on the page the CA was looking at.
44
+ def verify_all
45
+ model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
46
+ reviewed = CaReview.where(subject_type: model.name, subject_id: params[:ids]).pluck(:subject_id)
47
+ model.where(id: Array(params[:ids]) - reviewed.map(&:to_s)).find_each { |s| CaReview.verify!(s, actor: hr_current_user) }
48
+ redirect_back_to_list notice: "Verified everything shown."
49
+ end
50
+
51
+ def resolve
52
+ CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
53
+ redirect_back_to_list notice: "Marked resolved."
54
+ rescue ActiveRecord::RecordInvalid
55
+ redirect_back_to_list alert: "Only a flagged item can be resolved."
56
+ end
57
+
58
+ private
59
+
60
+ def require_any!(*keys)
61
+ return if keys.any? { |key| hr_can?(key, scope: :all) }
62
+
63
+ hr_require_permission!(keys.first, scope: :all)
64
+ end
65
+
66
+ def subject
67
+ model = params[:subject_type] == "HrLite::TaxDeclaration" ? TaxDeclaration : SalarySlip
68
+ model.find(params[:subject_id])
69
+ end
70
+
71
+ def redirect_back_to_list(**flash)
72
+ redirect_to admin_ca_reviews_path(params.permit(:fy, :tab, :status, :page).to_h), **flash
73
+ end
74
+ end
75
+ end
76
+ end
@@ -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,10 @@
1
1
  module HrLite
2
2
  module Admin
3
3
  class SalarySlipsController < SuperadminController
4
+ # A CA reads a slip to verify it (payroll.view, all) without running payroll.
5
+ skip_before_action :require_money_access!, only: :show
6
+ before_action -> { hr_require_permission!("payroll.view", scope: :all) }, only: :show
7
+
4
8
  def show
5
9
  @slip = SalarySlip.includes(:payroll_run).find(params[:id])
6
10
  @profile = @slip.user_profile
@@ -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
@@ -18,6 +18,8 @@ module HrLite
18
18
  { label: "ID card", path: :id_card_path, match: [ "/id_card" ], group: "Me" },
19
19
  { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ], group: "Me" },
20
20
  { label: "Timeline", path: :timeline_path, match: [ "/timeline" ], group: "Me" },
21
+ { label: "CA review", path: :admin_ca_reviews_path, match: [ "/admin/ca_reviews" ], group: "Company",
22
+ if: -> { hr_can?("payroll.verify", scope: :all) || hr_can?("payroll.approve", scope: :all) } },
21
23
  { label: "Policies", path: :policies_path, match: [ "/policies" ], group: "Company" },
22
24
  { label: "Ask HR", path: :hr_requests_path, match: [ "/hr_requests" ], group: "Company" },
23
25
  { label: "Org", path: :org_path, match: [ "/org" ], group: "Company" },
@@ -38,6 +40,11 @@ module HrLite
38
40
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
39
41
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
40
42
  { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
43
+ # HR approves payroll without running it; Super Admin reaches it under Organisation.
44
+ { label: "Payroll approval", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs" ],
45
+ if: -> { hr_can?("payroll.approve", scope: :all) && !hr_superadmin? } },
46
+ { label: "Tax checks", path: :admin_tax_declarations_path, match: [ "/admin/tax_declarations" ],
47
+ if: -> { hr_can?("tax.view", scope: :all) && !hr_superadmin? } },
41
48
  { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
42
49
  ].freeze
43
50
 
@@ -0,0 +1,19 @@
1
+ module HrLite
2
+ # Schedule on 31 March evening: anything the CA has not verified, or a flag
3
+ # nobody resolved, goes to payroll and HR as overdue.
4
+ class CaReviewOverdueJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(on: Date.current)
8
+ left = CaReview.outstanding(FinancialYear.start_for(on))
9
+ return if left.values.sum.zero?
10
+
11
+ people = HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a
12
+ Notifications.publish(
13
+ "ca.overdue",
14
+ title: "CA review overdue: #{left['slips']} slips, #{left['declarations']} declarations unverified, #{left['flagged']} flags open",
15
+ path: "/admin/ca_reviews?status=unreviewed", bell_to: people, email_to: people
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ # Schedule weekly in March: tells the CA reviewers what is left before 31 March.
3
+ class CaReviewReminderJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(on: Date.current)
7
+ return unless on.month == 3
8
+
9
+ left = CaReview.outstanding(FinancialYear.start_for(on))
10
+ return if left.values.sum.zero?
11
+
12
+ reviewers = HrLite.users_holding("payroll.verify", scope: :all).to_a
13
+ Notifications.publish(
14
+ "ca.reminder",
15
+ title: "CA review: #{left['slips']} slips and #{left['declarations']} declarations left before 31 March" \
16
+ "#{", #{left['flagged']} flagged" if left['flagged'].positive?}",
17
+ path: "/admin/ca_reviews", bell_to: reviewers, email_to: reviewers
18
+ )
19
+ end
20
+ end
21
+ end
@@ -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",
@@ -15,7 +15,7 @@ module HrLite
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
17
  HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
18
- HrLite::PerformancePlan
18
+ HrLite::PerformancePlan HrLite::CaReview
19
19
  ].freeze
20
20
 
21
21
  scope :recent, -> { order(created_at: :desc) }
@@ -0,0 +1,66 @@
1
+ module HrLite
2
+ # A chartered accountant's year-end mark on one salary slip or tax
3
+ # declaration. Verifying is final; a flag says what is wrong and stays open
4
+ # until HR or payroll fixes it the normal way (a correcting line on a later
5
+ # slip, or reopening the declaration) and resolves it. The slip itself is
6
+ # never edited.
7
+ class CaReview < ApplicationRecord
8
+ include Audited
9
+
10
+ SUBJECTS = %w[HrLite::SalarySlip HrLite::TaxDeclaration].freeze
11
+ STATUSES = %w[verified flagged resolved].freeze
12
+
13
+ belongs_to :subject, polymorphic: true
14
+ belongs_to :reviewed_by, class_name: HrLite.config.user_class
15
+ belongs_to :resolved_by, class_name: HrLite.config.user_class, optional: true
16
+
17
+ validates :subject_type, inclusion: { in: SUBJECTS }
18
+ validates :status, inclusion: { in: STATUSES }
19
+ validates :note, presence: true, if: -> { status == "flagged" }
20
+
21
+ # What the CA reviews for a financial year: every published slip, and
22
+ # every declaration somebody actually filed.
23
+ def self.slips_for(fy)
24
+ SalarySlip.joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published", period_month: fy...fy.next_year })
25
+ end
26
+
27
+ def self.declarations_for(fy) = TaxDeclaration.where(financial_year: fy).where.not(status: "draft")
28
+
29
+ # Still to do before 31 March: never reviewed, or flagged and not resolved.
30
+ def self.outstanding(fy)
31
+ { "slips" => slips_for(fy), "declarations" => declarations_for(fy) }.transform_values do |scope|
32
+ done = where(subject: scope, status: %w[verified resolved]).count
33
+ scope.count - done
34
+ end.merge("flagged" => where(subject_type: SUBJECTS, status: "flagged").count)
35
+ end
36
+
37
+ def self.verify!(subject, actor:)
38
+ review = find_or_initialize_by(subject: subject)
39
+ review.update!(status: "verified", note: nil, reviewed_by_id: actor.id, reviewed_at: Time.current)
40
+ review
41
+ end
42
+
43
+ def self.flag!(subject, actor:, note:)
44
+ review = find_or_initialize_by(subject: subject)
45
+ review.update!(status: "flagged", note: note.to_s.strip, reviewed_by_id: actor.id, reviewed_at: Time.current,
46
+ resolved_by_id: nil, resolved_at: nil, resolution_note: nil)
47
+ people = (HrLite.users_holding("payroll.approve", scope: :all).to_a | HrLite.users_holding("payroll.manage", scope: :all).to_a)
48
+ Notifications.publish("ca.flagged", title: "CA flagged #{review.subject_label}", body: review.note,
49
+ path: "/admin/ca_reviews?status=flagged", bell_to: people, email_to: people)
50
+ review
51
+ end
52
+
53
+ def resolve!(actor:, note:)
54
+ raise ActiveRecord::RecordInvalid.new(self), "not flagged" unless status == "flagged"
55
+
56
+ update!(status: "resolved", resolved_by_id: actor.id, resolved_at: Time.current, resolution_note: note.to_s.strip.presence)
57
+ end
58
+
59
+ def subject_label
60
+ case subject
61
+ when SalarySlip then "#{HrLite.display_name(subject.user)}'s #{subject.period_month.strftime('%B %Y')} slip"
62
+ else "#{HrLite.display_name(subject.user)}'s #{subject.label} tax declaration"
63
+ end
64
+ end
65
+ end
66
+ end