hr_lite 0.13.0 → 0.15.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/app/controllers/hr_lite/admin/assets_controller.rb +53 -0
  4. data/app/controllers/hr_lite/admin/checklists_controller.rb +32 -0
  5. data/app/controllers/hr_lite/admin/documents_controller.rb +63 -0
  6. data/app/controllers/hr_lite/admin/employees_controller.rb +4 -0
  7. data/app/controllers/hr_lite/admin/expenses_controller.rb +45 -0
  8. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +38 -0
  9. data/app/controllers/hr_lite/admin/loans_controller.rb +59 -0
  10. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +75 -0
  11. data/app/controllers/hr_lite/documents_controller.rb +69 -0
  12. data/app/controllers/hr_lite/loans_controller.rb +11 -0
  13. data/app/controllers/hr_lite/tax_declarations_controller.rb +100 -0
  14. data/app/helpers/hr_lite/application_helper.rb +14 -4
  15. data/app/models/hr_lite/approval_flow.rb +1 -1
  16. data/app/models/hr_lite/asset.rb +57 -0
  17. data/app/models/hr_lite/asset_assignment.rb +24 -0
  18. data/app/models/hr_lite/checklist_item.rb +43 -0
  19. data/app/models/hr_lite/checklist_template.rb +50 -0
  20. data/app/models/hr_lite/employee_profile.rb +13 -0
  21. data/app/models/hr_lite/tax_declaration.rb +4 -1
  22. data/app/views/hr_lite/admin/assets/index.html.erb +59 -0
  23. data/app/views/hr_lite/admin/assets/new.html.erb +23 -0
  24. data/app/views/hr_lite/admin/checklists/index.html.erb +50 -0
  25. data/app/views/hr_lite/admin/documents/index.html.erb +89 -0
  26. data/app/views/hr_lite/admin/documents/show.html.erb +42 -0
  27. data/app/views/hr_lite/admin/expenses/index.html.erb +54 -0
  28. data/app/views/hr_lite/admin/hr_requests/index.html.erb +41 -0
  29. data/app/views/hr_lite/admin/hr_requests/show.html.erb +47 -0
  30. data/app/views/hr_lite/admin/loans/index.html.erb +43 -0
  31. data/app/views/hr_lite/admin/loans/new.html.erb +39 -0
  32. data/app/views/hr_lite/admin/loans/show.html.erb +52 -0
  33. data/app/views/hr_lite/admin/tax_declarations/index.html.erb +37 -0
  34. data/app/views/hr_lite/admin/tax_declarations/show.html.erb +54 -0
  35. data/app/views/hr_lite/documents/index.html.erb +84 -0
  36. data/app/views/hr_lite/loans/index.html.erb +52 -0
  37. data/app/views/hr_lite/tax_declarations/show.html.erb +68 -0
  38. data/config/routes.rb +29 -0
  39. data/db/migrate/20260818010401_create_hr_lite_assets_and_onboarding.rb +76 -0
  40. data/lib/hr_lite/permissions.rb +3 -0
  41. data/lib/hr_lite/role_seeds.rb +5 -3
  42. data/lib/hr_lite/version.rb +1 -1
  43. data/lib/tasks/hr_lite_tasks.rake +2 -1
  44. metadata +32 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8202157b6f35309884c9df17895d711553e114a57925c8c2e381e51a7be863ba
4
- data.tar.gz: 3cb55e413c6bcc70bd3aa39a5c212690a21c9cf747b17bfdf5894ef6d73945de
3
+ metadata.gz: ec0aa9dccb203487f12a0e30cbbae7567da32b9589af4046fe86795c9a3cb5a4
4
+ data.tar.gz: 0e5b02a62ea2925115f71279dd9772f12a103b6cc17fb6d192f0225a3c95504e
5
5
  SHA512:
6
- metadata.gz: 0ddf504c20a84c58a712fd4efe695e9b90b27eea8acc8883bfb37d33df398778169c35eb2c11f37830e91c9ceec63560df57c631246b2b22381b52df5ce30144
7
- data.tar.gz: 4a724d4a55068129237e010aca71ce94c392656bb56953a6f1ec5329ddf5d33b6ffd91f76c82229a152f85d134a60ca29308634133f0a3a72779cff3cc6dafdb
6
+ metadata.gz: e1877020700a05db3f7a5a4a715be0c1e8e7f4ad447d3c42eaf6d17b60e0252ef68968eb58f431e7373e43d3452bae468d6e211eac64c3f4e9ca71703920d412
7
+ data.tar.gz: 28d5a185fd3af1373efb3b2db1e3d53d8b64e66ec676a7cfed01c15ffbb8ed2a07b0a921d4351ac6afca41db18e683ae9ad984e99b5ffbf6eeac32209e108af0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-08-18
11
+
12
+ The screens for three features that shipped as tables nobody could reach. No
13
+ migration — the data layer was already there.
14
+
15
+ ### Fixed
16
+
17
+ - **Documents, tax declarations and loans had no UI at all.** 0.13.0 and
18
+ 0.14.0 shipped their models, tables, payroll integration and an expiry job,
19
+ and every one of them passed its specs — but there was no controller, no
20
+ route and no view. An employee could not upload a passport, HR could not
21
+ verify a PAN, and nobody could file an 80C declaration. Loans at least
22
+ reached a payslip; the other two were unreachable rows.
23
+ - **A tax declaration 500ed when a real browser saved it.** The form posts a
24
+ line for every section, because that is what a list of claimable things
25
+ looks like, and the blank ones violated a NOT NULL amount. The specs had
26
+ only ever posted the lines they filled in. Blank sections are now dropped,
27
+ and clearing an existing amount withdraws that claim.
28
+ - `TaxDeclaration` and `ApprovalFlow` were missing `inverse_of` on their
29
+ nested associations. The foreign key is not derived from the class name, so
30
+ a nested item on an unsaved parent could not see it — every first save
31
+ failed with "declaration must exist".
32
+
33
+ ### Added
34
+
35
+ - **Documents.** Employees upload, see what is expiring and remove anything
36
+ not yet verified; HR sees what is waiting, whose file is missing which
37
+ category, and verifies or sends one back with a reason. Downloads go
38
+ through the row's own `readable_by?` and every one is audited — a file
39
+ leaving the building is the event worth recording.
40
+ - **Tax declarations.** A section-by-section claim the employee files
41
+ themselves, replacing an admin typing one lump sum into the profile. HR
42
+ records what the proof actually supported, which is often less than the
43
+ claim. Until it is verified payroll deducts against the CLAIM: making
44
+ somebody overpay tax all year because paperwork is slow is its own kind of
45
+ wrong.
46
+ - **Loans.** Employees see what they owe and what comes out of the next
47
+ payslip; the money tier records a loan, closes it, or cancels one that has
48
+ not been deducted from yet. Cancelling after an instalment has been taken
49
+ is refused — that money has already left somebody's salary.
50
+ - Nav entries for all three, employee and admin.
51
+
52
+ ### Changed
53
+
54
+ - Verifying a document requires being able to OPEN it. Attesting to a scan
55
+ you are not allowed to look at is not verification, and it keeps an
56
+ Aadhaar with the money tier rather than with whoever happens to run HR.
57
+ - The spec suite deletes its sqlite file before every run. The role and
58
+ rate-card migrations SEED rows, and seeds never overwrite what exists —
59
+ right in production, wrong in a test database, where a leftover file kept
60
+ yesterday's definitions and a change to `RoleSeeds` silently did not apply.
61
+
62
+ ## [0.14.0] - 2026-08-18
63
+
64
+ The admin half, plus the two ends of employment that were still manual.
65
+ **Adds one migration.**
66
+
67
+ ### Added
68
+
69
+ - **Admin screens for everything shipped since 0.11.0**: decide a claim and
70
+ record the payroll month it was paid with, answer or hand on a help-desk
71
+ request, hand out and take back an asset. The engine had all of this; there
72
+ was no way to do any of it.
73
+ - **Assets.** One live holder at a time, enforced by a partial unique index —
74
+ two open assignments would mean the laptop is in two places, which it is
75
+ not. Assignments are append-only and record what condition it came back in,
76
+ because "screen cracked" is the difference between an asset returned and an
77
+ asset written off.
78
+ - **Joining and leaving checklists**, from templates. The joining list opens
79
+ when a profile is created and the leaving list when an exit date is
80
+ stamped — the checklist nobody remembers to start is the one that does not
81
+ happen. Opening is idempotent, so adding a template later adds only what is
82
+ new. A failure to build one can never stop an employee being created.
83
+ - Each step names the permission its owner should hold, so IT taking back a
84
+ laptop and HR collecting a document are not the same person by accident.
85
+ - Permissions `asset.view`, `asset.manage`, `checklist.manage`.
86
+
87
+ ### Why it matters
88
+
89
+ An asset that is never returned is a cost nobody notices, and access that is
90
+ never revoked is a security problem that outlives the employment. Both were
91
+ previously somebody's memory.
92
+
10
93
  ## [0.13.0] - 2026-08-18
11
94
 
12
95
  The screens. No migration.
@@ -762,7 +845,9 @@ Initial release.
762
845
  bus with per-event channel matrix (bell, email, leadership email/bell),
763
846
  daily leadership digest and an append-only audit trail.
764
847
 
765
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...HEAD
848
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.15.0...HEAD
849
+ [0.15.0]: https://github.com/kshtzkr/hr_lite/compare/v0.14.0...v0.15.0
850
+ [0.14.0]: https://github.com/kshtzkr/hr_lite/compare/v0.13.0...v0.14.0
766
851
  [0.13.0]: https://github.com/kshtzkr/hr_lite/compare/v0.12.0...v0.13.0
767
852
  [0.12.0]: https://github.com/kshtzkr/hr_lite/compare/v0.11.0...v0.12.0
768
853
  [0.11.0]: https://github.com/kshtzkr/hr_lite/compare/v0.10.0...v0.11.0
@@ -0,0 +1,53 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who has the laptop.
4
+ class AssetsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_assets!
7
+
8
+ def index
9
+ @assets = paginate(Asset.order(:status, :name).includes(asset_assignments: :user))
10
+ @outstanding = AssetAssignment.live.includes(:asset, :user)
11
+ end
12
+
13
+ def new
14
+ @asset = Asset.new
15
+ end
16
+
17
+ def create
18
+ @asset = Asset.new(asset_params)
19
+ if @asset.save
20
+ redirect_to admin_assets_path, notice: "#{@asset.name} added."
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def assign
27
+ asset = Asset.find(params[:id])
28
+ user = HrLite.user_klass.find(params[:user_id])
29
+ asset.assign_to!(user, actor: hr_current_user)
30
+ redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
31
+ rescue ActiveRecord::RecordInvalid
32
+ redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
33
+ end
34
+
35
+ def take_back
36
+ asset = Asset.find(params[:id])
37
+ asset.return!(actor: hr_current_user, condition: params[:condition_note],
38
+ status: params[:status].presence_in(Asset::STATUSES) || "available")
39
+ redirect_to admin_assets_path, notice: "#{asset.name} taken back."
40
+ rescue ActiveRecord::RecordInvalid
41
+ redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
42
+ end
43
+
44
+ private
45
+
46
+ def require_assets! = hr_require_permission!("asset.manage", scope: :all)
47
+
48
+ def asset_params
49
+ params.require(:asset).permit(:name, :category, :serial_number, :purchased_on, :notes)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ module Admin
3
+ # Joining and leaving, step by step, with what is overdue at the top.
4
+ class ChecklistsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_checklists!
7
+
8
+ def index
9
+ @kind = params[:kind].presence_in(ChecklistTemplate::KINDS) || "onboarding"
10
+ @items = ChecklistItem.for_kind(@kind).outstanding.includes(:user)
11
+ .order(Arel.sql("due_on IS NULL, due_on"))
12
+ @overdue = @items.select(&:overdue?)
13
+ end
14
+
15
+ def complete
16
+ item = ChecklistItem.find(params[:id])
17
+ item.complete!(actor: hr_current_user, note: params[:note])
18
+ redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — done."
19
+ end
20
+
21
+ def reopen
22
+ item = ChecklistItem.find(params[:id])
23
+ item.reopen!(actor: hr_current_user)
24
+ redirect_to admin_checklists_path(kind: item.kind), notice: "#{item.title} — reopened."
25
+ end
26
+
27
+ private
28
+
29
+ def require_checklists! = hr_require_permission!("checklist.manage", scope: :all)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,63 @@
1
+ module HrLite
2
+ module Admin
3
+ # HR's side: whose documents are missing, and verifying the ones that
4
+ # arrived. Reaching a person's list is `document.view`; opening the file
5
+ # still goes through the row's own visibility.
6
+ class DocumentsController < BaseController
7
+ skip_before_action :require_operations_access!
8
+ before_action :require_document_access!
9
+
10
+ def index
11
+ @category = params[:category].presence
12
+ scope = hr_scope(Document.includes(:user).recent_first, "document.view")
13
+ scope = scope.where(category: @category) if @category
14
+ @pending = scope.where(verification: "pending").limit(50).to_a
15
+ @documents = paginate(scope)
16
+ @categories = Document.distinct.order(:category).pluck(:category)
17
+ end
18
+
19
+ def show
20
+ @employee = HrLite.user_klass.find(params[:user_id])
21
+ hr_require_reach!("document.view", @employee)
22
+ @documents = Document.for_user(@employee).recent_first
23
+ @missing = Document::SENSITIVE_CATEGORIES - @documents.map(&:category)
24
+ end
25
+
26
+ def verify
27
+ document = find_manageable
28
+ document.verify!(actor: hr_current_user, note: params[:note])
29
+ redirect_back fallback_location: admin_documents_path,
30
+ notice: "#{document.title} verified."
31
+ end
32
+
33
+ def reject
34
+ document = find_manageable
35
+ note = params[:note].to_s.strip
36
+ if note.blank?
37
+ return redirect_back fallback_location: admin_documents_path,
38
+ alert: "Say what is wrong with it — the employee has to re-upload."
39
+ end
40
+
41
+ document.reject!(actor: hr_current_user, note: note)
42
+ redirect_back fallback_location: admin_documents_path, notice: "#{document.title} rejected."
43
+ end
44
+
45
+ private
46
+
47
+ def require_document_access!
48
+ hr_require_permission!("document.view", scope: :team)
49
+ end
50
+
51
+ # You may verify a document you can OPEN. Attesting to a scan you are
52
+ # not allowed to look at is not verification, and the row's visibility
53
+ # is what keeps an Aadhaar with the money tier rather than with
54
+ # whoever happens to run HR.
55
+ def find_manageable
56
+ document = Document.find(params[:id])
57
+ raise ActiveRecord::RecordNotFound unless document.readable_by?(hr_current_user)
58
+
59
+ document
60
+ end
61
+ end
62
+ end
63
+ end
@@ -93,6 +93,10 @@ module HrLite
93
93
  exit_date = params[:date_of_exit].present? ? parse_date_param(params[:date_of_exit]) : Date.current
94
94
 
95
95
  @profile.update!(date_of_exit: exit_date)
96
+ # The last day has a list too — and it is the one that matters most,
97
+ # since an unreturned laptop and un-revoked access both outlive the
98
+ # employment.
99
+ ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
96
100
  begin
97
101
  HrLite.config.offboard_user.call(@profile.user)
98
102
  rescue => e
@@ -0,0 +1,45 @@
1
+ module HrLite
2
+ module Admin
3
+ # The other side of the claim: decide it, then say when it was paid.
4
+ class ExpensesController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_deciding!
7
+
8
+ def index
9
+ @status = params[:status].presence_in(Expense::STATUSES) || "submitted"
10
+ scope = Expense.where(status: @status).includes(:user, :category).recent_first
11
+ @expenses = paginate(hr_scope(scope, "expense.approve"))
12
+ end
13
+
14
+ def approve
15
+ expense = decidable.find(params[:id])
16
+ expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
17
+ redirect_to admin_expenses_path, notice: "Claim approved."
18
+ end
19
+
20
+ def reject
21
+ expense = decidable.find(params[:id])
22
+ note = params[:decision_note].to_s.strip
23
+ return redirect_to admin_expenses_path, alert: "A note is required to reject." if note.blank?
24
+
25
+ expense.reject!(actor: hr_current_user, note: note)
26
+ redirect_to admin_expenses_path, notice: "Claim rejected."
27
+ end
28
+
29
+ def reimburse
30
+ expense = decidable.find(params[:id])
31
+ month = parse_month_param(params[:period_month])
32
+ expense.reimburse!(actor: hr_current_user, period_month: month)
33
+ redirect_to admin_expenses_path, notice: "Marked reimbursed with #{month.strftime('%B %Y')}."
34
+ rescue ActiveRecord::RecordInvalid
35
+ redirect_to admin_expenses_path, alert: "Only an approved claim can be reimbursed."
36
+ end
37
+
38
+ private
39
+
40
+ def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
41
+
42
+ def decidable = hr_scope(Expense.all, "expense.approve")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ module HrLite
2
+ module Admin
3
+ # The desk itself.
4
+ class HrRequestsController < BaseController
5
+ skip_before_action :require_operations_access!
6
+ before_action :require_desk!
7
+
8
+ def index
9
+ @status = params[:status].presence_in(HrRequest::STATUSES) || "open"
10
+ @requests = paginate(HrRequest.where(status: @status).includes(:user, :assigned_to)
11
+ .recent_first)
12
+ end
13
+
14
+ def show
15
+ @request = HrRequest.find(params[:id])
16
+ end
17
+
18
+ def assign
19
+ request = HrRequest.find(params[:id])
20
+ assignee = HrLite.user_klass.find(params[:assignee_id])
21
+ request.assign!(actor: hr_current_user, assignee: assignee)
22
+ redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
23
+ end
24
+
25
+ def resolve
26
+ request = HrRequest.find(params[:id])
27
+ request.resolve!(actor: hr_current_user, resolution: params[:resolution].to_s.strip)
28
+ redirect_to admin_hr_requests_path, notice: "Answered."
29
+ rescue ArgumentError
30
+ redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
31
+ end
32
+
33
+ private
34
+
35
+ def require_desk! = hr_require_permission!("hr_request.manage", scope: :all)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,59 @@
1
+ module HrLite
2
+ module Admin
3
+ # Loans and salary advances. Money tier: the instalment lands as a
4
+ # deduction on a payslip, so whoever sets it is setting somebody's pay.
5
+ class LoansController < SuperadminController
6
+ def index
7
+ @status = params[:status].presence_in(Loan::STATUSES) || "active"
8
+ @loans = paginate(Loan.includes(:user).where(status: @status).order(starts_on: :desc))
9
+ @outstanding = Loan.active.includes(:loan_repayments).sum(BigDecimal(0), &:outstanding)
10
+ end
11
+
12
+ def new
13
+ @loan = Loan.new(starts_on: Date.current.next_month.beginning_of_month)
14
+ end
15
+
16
+ def create
17
+ @loan = Loan.new(loan_params.merge(approved_by_id: hr_current_user.id))
18
+ if @loan.save
19
+ redirect_to admin_loans_path,
20
+ notice: "Loan recorded for #{HrLite.display_name(@loan.user)} — " \
21
+ "deductions start #{@loan.starts_on.strftime('%b %Y')}."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def show
28
+ @loan = Loan.includes(:loan_repayments).find(params[:id])
29
+ end
30
+
31
+ # Closing stops the deduction. It does NOT delete the repayments —
32
+ # those are on published payslips and are what the outstanding balance
33
+ # is derived from.
34
+ def close
35
+ loan = Loan.find(params[:id])
36
+ loan.update!(status: "closed")
37
+ redirect_to admin_loans_path, notice: "Closed — no further deductions."
38
+ end
39
+
40
+ def cancel
41
+ loan = Loan.find(params[:id])
42
+ if loan.loan_repayments.exists?
43
+ return redirect_to admin_loan_path(loan),
44
+ alert: "Instalments have already been deducted. Close it instead — " \
45
+ "cancelling would deny money that has left somebody's salary."
46
+ end
47
+
48
+ loan.update!(status: "cancelled")
49
+ redirect_to admin_loans_path, notice: "Cancelled before any deduction."
50
+ end
51
+
52
+ private
53
+
54
+ def loan_params
55
+ params.require(:loan).permit(:user_id, :principal, :monthly_instalment, :starts_on, :reason)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ module HrLite
2
+ module Admin
3
+ # HR checking the proof behind a declaration, line by line. The verified
4
+ # amount is what payroll deducts against once this is done, so it is
5
+ # entered per section rather than accepted wholesale.
6
+ class TaxDeclarationsController < BaseController
7
+ skip_before_action :require_operations_access!
8
+ before_action :require_tax_access!
9
+
10
+ def index
11
+ @status = params[:status].presence_in(TaxDeclaration::STATUSES) || "submitted"
12
+ @declarations = paginate(
13
+ hr_scope(TaxDeclaration.includes(:user, :tax_declaration_items), "tax.view")
14
+ .where(status: @status).order(created_at: :desc)
15
+ )
16
+ end
17
+
18
+ def show
19
+ @declaration = find_visible
20
+ end
21
+
22
+ def update
23
+ @declaration = find_manageable
24
+ # Recording what the proof supported is a separate act from deciding
25
+ # — an amount can be corrected while the decision is still pending.
26
+ if @declaration.update(verification_params)
27
+ redirect_to admin_tax_declaration_path(@declaration), notice: "Amounts recorded."
28
+ else
29
+ render :show, status: :unprocessable_entity
30
+ end
31
+ end
32
+
33
+ def verify
34
+ declaration = find_manageable
35
+ declaration.verify!(actor: hr_current_user, note: params[:note])
36
+ redirect_to admin_tax_declarations_path, notice: "Verified — payroll will use the checked amounts."
37
+ rescue ActiveRecord::RecordInvalid
38
+ redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be verified."
39
+ end
40
+
41
+ def reject
42
+ declaration = find_manageable
43
+ declaration.reject!(actor: hr_current_user, note: params[:note].to_s.strip)
44
+ redirect_to admin_tax_declarations_path, notice: "Sent back to the employee."
45
+ rescue ArgumentError
46
+ redirect_to admin_tax_declaration_path(declaration),
47
+ alert: "Say what is missing — they have to know what to fix."
48
+ end
49
+
50
+ private
51
+
52
+ def require_tax_access!
53
+ hr_require_permission!("tax.view", scope: :all)
54
+ end
55
+
56
+ def find_visible
57
+ declaration = TaxDeclaration.includes(:tax_declaration_items).find(params[:id])
58
+ hr_require_reach!("tax.view", declaration.user)
59
+ declaration
60
+ end
61
+
62
+ def find_manageable
63
+ declaration = TaxDeclaration.find(params[:id])
64
+ hr_require_reach!("tax.manage", declaration.user)
65
+ declaration
66
+ end
67
+
68
+ def verification_params
69
+ params.require(:tax_declaration).permit(
70
+ tax_declaration_items_attributes: %i[id verified_amount]
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,69 @@
1
+ module HrLite
2
+ # An employee's own documents, and the download itself.
3
+ #
4
+ # The FILE is the sensitive part, so every read goes through
5
+ # `Document#readable_by?` rather than through the scope that found the row.
6
+ # A passport is not a payslip: HR can see that one exists without being
7
+ # handed the scan.
8
+ class DocumentsController < ApplicationController
9
+ def index
10
+ @documents = Document.for_user(hr_current_user).recent_first
11
+ @document = Document.new
12
+ @expiring = @documents.select { |d| d.expires_on && d.expires_on <= 60.days.from_now.to_date }
13
+ end
14
+
15
+ def create
16
+ @document = Document.new(document_params)
17
+ @document.user_id = hr_current_user.id
18
+ @document.uploaded_by_id = hr_current_user.id
19
+
20
+ if @document.save
21
+ redirect_to documents_path, notice: "#{@document.title} uploaded."
22
+ else
23
+ @documents = Document.for_user(hr_current_user).recent_first
24
+ @expiring = []
25
+ render :index, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def destroy
30
+ document = Document.for_user(hr_current_user).find(params[:id])
31
+ # A verified identity document is evidence HR acted on; removing it
32
+ # quietly would leave their verification pointing at nothing.
33
+ if document.verified?
34
+ return redirect_to documents_path, status: :see_other,
35
+ alert: "#{document.title} has been verified — ask HR to replace it."
36
+ end
37
+
38
+ document.destroy!
39
+ redirect_to documents_path, notice: "#{document.title} removed.", status: :see_other
40
+ end
41
+
42
+ # The one place a file leaves the building. Scoped by nothing — the
43
+ # permission check is the row's own, because a document may be readable
44
+ # by its owner, by HR, or by nobody but the money tier.
45
+ def download
46
+ document = Document.find(params[:id])
47
+ unless document.readable_by?(hr_current_user)
48
+ raise ActiveRecord::RecordNotFound
49
+ end
50
+
51
+ AuditLog.record!(
52
+ action: "document.downloaded", subject: document, actor: hr_current_user,
53
+ changes: { "title" => document.title, "category" => document.category,
54
+ "owner" => HrLite.display_name(document.user) }
55
+ )
56
+ # rails_blob_path, not url_for: an engine controller has no default
57
+ # host and url_for(attachment) raises without one.
58
+ redirect_to Rails.application.routes.url_helpers.rails_blob_path(
59
+ document.file, disposition: "attachment", only_path: true
60
+ ), allow_other_host: false
61
+ end
62
+
63
+ private
64
+
65
+ def document_params
66
+ params.require(:document).permit(:category, :title, :reference_number, :issued_on, :expires_on, :file)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,11 @@
1
+ module HrLite
2
+ # What an employee owes and what is coming out of the next payslip. Read
3
+ # only: a loan is agreed with the company, not self-served.
4
+ class LoansController < ApplicationController
5
+ def index
6
+ @loans = Loan.where(user_id: hr_current_user.id).order(starts_on: :desc)
7
+ @outstanding = @loans.sum(BigDecimal(0), &:outstanding)
8
+ @next_month = Date.current.next_month.beginning_of_month
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,100 @@
1
+ module HrLite
2
+ # The employee's own declaration for a financial year. Replaces an admin
3
+ # typing one lump sum into the profile: the whole year's TDS is projected
4
+ # from this, so the person whose money it is fills it in.
5
+ class TaxDeclarationsController < ApplicationController
6
+ def show
7
+ @declaration = current_declaration || build_declaration
8
+ @regime = @declaration.regime
9
+ end
10
+
11
+ def update
12
+ # A bare record, not the form's scaffold: `build_declaration` fills in
13
+ # one blank line per section so the form is a list rather than an empty
14
+ # box, and saving those blanks fails a NOT NULL amount.
15
+ @declaration = current_declaration ||
16
+ TaxDeclaration.new(user_id: hr_current_user.id,
17
+ financial_year: financial_year, regime: employee_regime)
18
+ # A verified declaration is what payroll is deducting against; changing
19
+ # it would silently re-open a decision HR has already made.
20
+ if @declaration.verified?
21
+ return redirect_to tax_declaration_path,
22
+ alert: "This year's declaration is verified. Ask HR to reopen it."
23
+ end
24
+
25
+ if @declaration.update(declaration_params)
26
+ redirect_to tax_declaration_path, notice: "Saved."
27
+ else
28
+ # Re-render needs the full set of lines back, or the form loses the
29
+ # sections the person had not filled in yet.
30
+ fill_missing_sections(@declaration)
31
+ render :show, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def submit
36
+ @declaration = current_declaration
37
+ return redirect_to tax_declaration_path, alert: "Nothing to submit yet." if @declaration.nil?
38
+
39
+ @declaration.submit!(actor: hr_current_user)
40
+ redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
41
+ rescue ActiveRecord::RecordInvalid
42
+ redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
43
+ end
44
+
45
+ private
46
+
47
+ def financial_year
48
+ FinancialYear.start_for(params[:financial_year].present? ? Date.parse(params[:financial_year]) : Date.current)
49
+ rescue Date::Error
50
+ FinancialYear.start_for(Date.current)
51
+ end
52
+
53
+ def current_declaration
54
+ TaxDeclaration.find_by(user_id: hr_current_user.id, financial_year: financial_year)
55
+ end
56
+
57
+ def build_declaration
58
+ declaration = TaxDeclaration.new(user_id: hr_current_user.id, financial_year: financial_year,
59
+ regime: employee_regime)
60
+ # One blank line per section, so the form is a list of the things a
61
+ # person can actually claim rather than an empty box and a plus button.
62
+ fill_missing_sections(declaration)
63
+ declaration
64
+ end
65
+
66
+ # Every section gets a line, whether or not it has been claimed yet.
67
+ def fill_missing_sections(declaration)
68
+ present = declaration.tax_declaration_items.map(&:section)
69
+ (TaxDeclarationItem::SECTIONS - present).each do |section|
70
+ declaration.tax_declaration_items.build(section: section)
71
+ end
72
+ end
73
+
74
+ def employee_regime
75
+ EmployeeProfile.find_by(user_id: hr_current_user.id)&.tax_regime.presence || "new"
76
+ end
77
+
78
+ # The form posts a line for every section, because that is what a list of
79
+ # claimable things looks like. Most of them are blank — a section with no
80
+ # amount is not a claim, and saving it violates a NOT NULL amount.
81
+ # An EXISTING line blanked out is a claim being withdrawn, so it is
82
+ # destroyed rather than skipped.
83
+ def declaration_params
84
+ permitted = params.require(:tax_declaration).permit(
85
+ :regime,
86
+ tax_declaration_items_attributes: %i[id section label declared_amount _destroy]
87
+ )
88
+ items = permitted[:tax_declaration_items_attributes]
89
+ return permitted if items.blank?
90
+
91
+ permitted[:tax_declaration_items_attributes] = items.to_h.filter_map { |key, row|
92
+ next [ key, row.merge("_destroy" => "1") ] if row["declared_amount"].blank? && row["id"].present?
93
+ next if row["declared_amount"].blank?
94
+
95
+ [ key, row ]
96
+ }.to_h
97
+ permitted
98
+ end
99
+ end
100
+ end