hr_lite 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +1 -0
  4. data/app/controllers/hr_lite/admin/benefits_controller.rb +61 -0
  5. data/app/controllers/hr_lite/admin/expense_categories_controller.rb +43 -0
  6. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -1
  7. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +3 -3
  8. data/app/controllers/hr_lite/admin/payroll_line_items_controller.rb +47 -0
  9. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +3 -1
  10. data/app/controllers/hr_lite/leave_requests_controller.rb +3 -1
  11. data/app/controllers/hr_lite/salary_slips_controller.rb +6 -6
  12. data/app/helpers/hr_lite/application_helper.rb +12 -6
  13. data/app/jobs/hr_lite/payroll_auto_draft_job.rb +4 -3
  14. data/app/jobs/hr_lite/slips_ready_job.rb +10 -0
  15. data/app/models/hr_lite/audit_log.rb +1 -1
  16. data/app/models/hr_lite/benefit_enrolment.rb +1 -1
  17. data/app/models/hr_lite/leave_balance.rb +2 -1
  18. data/app/models/hr_lite/leave_request.rb +38 -17
  19. data/app/models/hr_lite/payroll_line_item.rb +5 -3
  20. data/app/models/hr_lite/payroll_run.rb +19 -3
  21. data/app/models/hr_lite/salary_slip.rb +6 -0
  22. data/app/services/hr_lite/attendance_summary.rb +12 -15
  23. data/app/views/hr_lite/admin/benefits/index.html.erb +65 -0
  24. data/app/views/hr_lite/admin/benefits/new.html.erb +37 -0
  25. data/app/views/hr_lite/admin/expense_categories/_form.html.erb +16 -0
  26. data/app/views/hr_lite/admin/expense_categories/edit.html.erb +3 -0
  27. data/app/views/hr_lite/admin/expense_categories/index.html.erb +30 -0
  28. data/app/views/hr_lite/admin/expense_categories/new.html.erb +3 -0
  29. data/app/views/hr_lite/admin/leave_balances/index.html.erb +4 -0
  30. data/app/views/hr_lite/admin/leave_requests/show.html.erb +3 -0
  31. data/app/views/hr_lite/admin/leave_types/index.html.erb +1 -0
  32. data/app/views/hr_lite/admin/payroll_line_items/index.html.erb +47 -0
  33. data/app/views/hr_lite/admin/payroll_line_items/new.html.erb +42 -0
  34. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +1 -0
  35. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +5 -1
  36. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +3 -1
  37. data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
  38. data/app/views/hr_lite/leave_requests/new.html.erb +1 -0
  39. data/app/views/hr_lite/salary_slips/index.html.erb +1 -1
  40. data/app/views/layouts/hr_lite/application.html.erb +4 -4
  41. data/config/routes.rb +5 -0
  42. data/db/migrate/20260929081109_add_paid_days_to_hr_lite_leave_requests.rb +5 -0
  43. data/lib/hr_lite/configuration.rb +11 -1
  44. data/lib/hr_lite/notifications.rb +6 -2
  45. data/lib/hr_lite/version.rb +1 -1
  46. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14d37a1d62a035315390be2c619a74722c58e6b53b747422aa4ab48458287102
4
- data.tar.gz: fbbb6c7efb893ff38d6992971844fd721deca4819943f0e0dd8f1cd343f4b4fc
3
+ metadata.gz: c73e1512e67fbae1abb7b12f6134564974819e2055c937e3fabcb294a196bea9
4
+ data.tar.gz: 93bd305c7aa148e6e2f034f9355cc15078ddf8252cf0af92f938da136b945ac1
5
5
  SHA512:
6
- metadata.gz: 70b42e3a35bd683ab62ecda90d33009511faeda8595cbc9ec9fc9f1ae2388f61592059a4ea1be151ade6ec3062f4ecea2726098f2f58adc55048f8e108de7460
7
- data.tar.gz: a19199e7cd8a173713e97d77266d4779951ef47d70171c234ee65d2afec5cc35be8670542db4e49ad2e656efe6031b8aba053cc281f687fd08dd6b20e3d83511
6
+ metadata.gz: f4c2331de1a65bbfb074277c9a309b1276c213a55747fd8f8446c5d54c72a41edfb3087a950bfd19aff5fc00ed2b96130e20d200583e9e8970dc0edd10750806
7
+ data.tar.gz: 25103c4830b09f09e13996557e926dfb4bde694fb0cb60a8f10d8adb355ba4f25d553b226ec38f039df395a339a832c4822ecd2a4aaa5ebbf5b6f111a957e88c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-09-29
11
+
12
+ Salary follows leave. One migration (`paid_days` on leave requests, nullable;
13
+ existing rows read as fully paid).
14
+
15
+ ### Changed
16
+
17
+ - **Leave beyond the balance is loss of pay, not refused.** On approval —
18
+ applied by the employee or recorded by HR — the balance covers the leave's
19
+ earliest working days in half-day steps (`paid_days`), and payroll cuts the
20
+ rest at the month's gross divided by its calendar days. The balance is never
21
+ overdrawn by it, so next month's accrual is not swallowed. Each unpaid day is
22
+ cut in its own month when a leave spans two. The split is fixed at approval;
23
+ correct it with the slip's LOP override while the run is in review.
24
+ - Comp-off is still refused past its earned credit.
25
+ - The monthly draft alert on the 1st goes to whoever runs payroll
26
+ (`payroll.manage`), with email, instead of leadership — who cannot open it.
27
+
28
+ ### Added
29
+
30
+ - The employee is told on applying how many days will be unpaid; the approver
31
+ sees it on the request before approving.
32
+ - `config.slip_release_day` (1..28): employees see a published slip from that
33
+ day of the following month, and the "slip ready" bell and email wait until
34
+ then. `nil` (default) keeps today's behaviour. Needs an ActiveJob backend
35
+ that runs scheduled jobs.
36
+
37
+ ## [0.17.0] - 2026-09-29
38
+
39
+ Screens for the things other screens were waiting on. An audit of every HR
40
+ screen found four that could never show anything, because the rows they list
41
+ could only be created from a console. No migration.
42
+
43
+ ### Added
44
+
45
+ - **Expense categories** (Settings → Expense categories, leadership). Until
46
+ now nothing created one, so the claim form had no categories and every
47
+ expense claim failed with "Category must exist". Add or edit name, monthly
48
+ cap, receipt rule and active; untick Active to retire one (no delete —
49
+ claims point at it).
50
+ - **One-off pay items** (Payroll → a run → One-off pay items, money tier): a
51
+ bonus, incentive, arrears, reimbursement or one-time deduction for a person
52
+ and month, carried by that month's computed slip. A month already paid
53
+ refuses both adding and removing an item; the loan repayment line stays
54
+ with the loan. Items are money-tier in the audit trail, so the leadership
55
+ email no longer carries the amount.
56
+ - **Benefits admin** (Manage → Benefits, `benefit.manage`): add a benefit,
57
+ enrol somebody with their dependants, end their cover. Employees' Benefits
58
+ page was always empty before.
59
+
60
+ ### Fixed
61
+
62
+ - **A manager could adjust anybody's leave balance.** The adjust action never
63
+ asked whose balance it was. It now needs `leave.manage` reaching that person;
64
+ the balances grid lists only people the viewer can see, and the adjust form
65
+ shows only to someone who holds `leave.manage`.
66
+ - The professional-tax state picker on the salary structure form had no
67
+ options.
68
+ - Sidebar links that led nowhere: the employee Approvals inbox shows only once
69
+ an approval flow exists; Claims shows to whoever approves claims (Finance
70
+ sees it, HR no longer lands on access denied); Profile is linked, and with it
71
+ Resignation; both leave balance pages are linked.
72
+
10
73
  ## [0.16.0] - 2026-09-27
11
74
 
12
75
  Employee ID cards, print requests through Ask HR, recording leave for
data/README.md CHANGED
@@ -141,6 +141,7 @@ HrLite.configure do |c|
141
141
  c.render_pdf = ->(template:, assigns:, cache_key:) {
142
142
  PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
143
143
  }
144
+ c.slip_release_day = 10 # employees see a slip from the 10th of the next month; nil (default) = on publish
144
145
 
145
146
  # Mirror promotions into your own user model (optional).
146
147
  c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
@@ -0,0 +1,61 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who is covered by what. Before this screen nothing created a benefit,
4
+ # so every employee's /benefits page said "not enrolled".
5
+ class BenefitsController < BaseController
6
+ skip_before_action :require_operations_access!
7
+ before_action :require_benefits!
8
+
9
+ def index
10
+ @benefits = Benefit.order(:name)
11
+ @enrolments = paginate(covered.includes(:benefit, :user).order(:enrolled_on))
12
+ end
13
+
14
+ def new
15
+ @benefit = Benefit.new
16
+ end
17
+
18
+ def create
19
+ @benefit = Benefit.new(benefit_params)
20
+ if @benefit.save
21
+ redirect_to admin_benefits_path, notice: "#{@benefit.name} added."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def enrol
28
+ benefit = Benefit.find(params[:id])
29
+ user = HrLite.user_klass.find(params[:user_id])
30
+ benefit.benefit_enrolments.create!(user_id: user.id, enrolled_on: parse_date_param(params[:enrolled_on]),
31
+ dependants: params[:dependants].presence || 0)
32
+ redirect_to admin_benefits_path, notice: "#{HrLite.display_name(user)} enrolled in #{benefit.name}."
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
35
+ end
36
+
37
+ # Ends cover rather than deleting the row: who was covered when is
38
+ # the record an insurance claim gets checked against.
39
+ def unenrol
40
+ enrolment = covered.where(benefit_id: params[:id]).find_by!(user_id: params[:user_id])
41
+ enrolment.update!(ended_on: parse_date_param(params[:ended_on]))
42
+ redirect_to admin_benefits_path, notice: "Cover ended for #{HrLite.display_name(enrolment.user)}."
43
+ rescue ActiveRecord::RecordInvalid => e
44
+ redirect_to admin_benefits_path, alert: e.record.errors.full_messages.to_sentence
45
+ end
46
+
47
+ private
48
+
49
+ def require_benefits! = hr_require_permission!("benefit.manage", scope: :all)
50
+
51
+ # Still covered today — including cover that ends on a future date, which
52
+ # HR must still be able to see and change.
53
+ def covered = BenefitEnrolment.where("ended_on IS NULL OR ended_on >= ?", Date.current)
54
+
55
+ def benefit_params
56
+ params.require(:benefit).permit(:name, :kind, :provider, :policy_number, :coverage, :employer_premium,
57
+ :employee_premium, :effective_from, :expires_on, :notes)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ module HrLite
2
+ module Admin
3
+ # No destroy: claims hold the category by foreign key. Retire one by
4
+ # unticking Active, which takes it off the claim form.
5
+ class ExpenseCategoriesController < LeadershipController
6
+ def index
7
+ @expense_categories = ExpenseCategory.alphabetical
8
+ end
9
+
10
+ def new
11
+ @expense_category = ExpenseCategory.new
12
+ end
13
+
14
+ def create
15
+ @expense_category = ExpenseCategory.new(expense_category_params)
16
+ if @expense_category.save
17
+ redirect_to admin_expense_categories_path, notice: "Category added."
18
+ else
19
+ render :new, status: :unprocessable_entity
20
+ end
21
+ end
22
+
23
+ def edit
24
+ @expense_category = ExpenseCategory.find(params[:id])
25
+ end
26
+
27
+ def update
28
+ @expense_category = ExpenseCategory.find(params[:id])
29
+ if @expense_category.update(expense_category_params)
30
+ redirect_to admin_expense_categories_path, notice: "Category updated."
31
+ else
32
+ render :edit, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def expense_category_params
39
+ params.require(:expense_category).permit(:name, :monthly_cap, :receipt_required, :active)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,12 +4,15 @@ module HrLite
4
4
  def index
5
5
  @year = sanitized_year
6
6
  @types = LeaveType.active.where(paid: true).where.not(annual_quota: nil)
7
- @employees = HrLite.employees
7
+ @employees = HrLite.employees.select { |employee| hr_reaches?("leave.view", employee) }
8
8
  end
9
9
 
10
10
  # Manual credit/debit — also the comp-off credit mechanism.
11
11
  def adjust
12
12
  user = HrLite.user_klass.find(params[:user_id])
13
+ # A manager reaches this screen through leave.approve; changing
14
+ # somebody's quota is leave.manage, for THAT person.
15
+ hr_require_reach!("leave.manage", user)
13
16
  type = LeaveType.find(params[:leave_type_id])
14
17
  # Unsanitised, a missing param wrote the adjustment into leave year 0,
15
18
  # where no screen can ever show it.
@@ -17,7 +17,7 @@ module HrLite
17
17
  end
18
18
 
19
19
  # HR records leave somebody forgot to apply for. Approved on save, by the
20
- # person recording it; the balance check still applies.
20
+ # person recording it; days beyond the balance become loss of pay.
21
21
  def new
22
22
  @request = LeaveRequest.new(start_date: Date.current, end_date: Date.current)
23
23
  @people = recordable
@@ -32,7 +32,7 @@ module HrLite
32
32
  end
33
33
  return redirect_to(admin_leave_request_path(@request), notice: "Leave recorded and approved.") if recorded
34
34
 
35
- @request.errors.add(:base, profile ? "Not enough balance — adjust it first or use unpaid leave" : "Pick an employee from the list") if @request.errors.empty?
35
+ @request.errors.add(:base, profile ? "Not enough comp-off credit" : "Pick an employee from the list") if @request.errors.empty?
36
36
  @people = recordable
37
37
  render :new, status: :unprocessable_entity
38
38
  end
@@ -43,7 +43,7 @@ module HrLite
43
43
  redirect_to admin_leave_requests_path, notice: "Leave approved."
44
44
  else
45
45
  redirect_to admin_leave_request_path(request),
46
- alert: "Cannot approve — balance no longer covers this request."
46
+ alert: "Cannot approve — comp-off credit no longer covers this request."
47
47
  end
48
48
  rescue ActiveRecord::RecordInvalid
49
49
  redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
@@ -0,0 +1,47 @@
1
+ module HrLite
2
+ module Admin
3
+ # One-off payslip lines: a bonus, an incentive, arrears, a reimbursement,
4
+ # a one-time deduction. Money tier, like loans — this is somebody's pay.
5
+ # Create and destroy are audited by PayrollLineItem's Audited concern.
6
+ class PayrollLineItemsController < SuperadminController
7
+ def index
8
+ @month = parse_month_param(params[:month])
9
+ @items = paginate(PayrollLineItem.for_month(@month).includes(:user, :component).order(:created_at))
10
+ end
11
+
12
+ def new
13
+ @item = PayrollLineItem.new(period_month: parse_month_param(params[:month]))
14
+ end
15
+
16
+ def create
17
+ @item = PayrollLineItem.new(item_params.merge(
18
+ period_month: parse_month_param(params.dig(:payroll_line_item, :period_month)),
19
+ created_by_id: hr_current_user.id,
20
+ # Same list the form offers: loan repayments come from the loan itself.
21
+ component: addable_components.find_by(id: params.dig(:payroll_line_item, :component_id))
22
+ ))
23
+ if @item.save
24
+ redirect_to admin_payroll_line_items_path(month: @item.period_month.strftime("%Y-%m")),
25
+ notice: "#{@item.component.label} added for #{HrLite.display_name(@item.user)}."
26
+ else
27
+ render :new, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def destroy
32
+ item = PayrollLineItem.find(params[:id])
33
+ removed = item.destroy
34
+ redirect_to admin_payroll_line_items_path(month: item.period_month.strftime("%Y-%m")), status: :see_other,
35
+ **(removed ? { notice: "Removed." } : { alert: "That month has already been paid — it can't change now." })
36
+ end
37
+
38
+ private
39
+
40
+ def item_params
41
+ params.require(:payroll_line_item).permit(:user_id, :amount, :note)
42
+ end
43
+
44
+ def addable_components = SalaryComponent.active.where.not(code: "loan_repayment")
45
+ end
46
+ end
47
+ end
@@ -48,7 +48,9 @@ module HrLite
48
48
  end
49
49
 
50
50
  def publish
51
- transition { |run| run.publish!(actor: hr_current_user) && "Published — employees notified." }
51
+ day = HrLite.config.slip_release_day
52
+ opens = day && "Published — employees can open it, and are notified, from the #{day.ordinalize} of next month."
53
+ transition { |run| run.publish!(actor: hr_current_user) && (opens || "Published — employees notified.") }
52
54
  end
53
55
 
54
56
  # Payout register: the full money sheet including bank details —
@@ -19,7 +19,9 @@ module HrLite
19
19
  def create
20
20
  @request = LeaveRequest.new(request_params.merge(user_id: hr_current_user.id))
21
21
  if @request.save
22
- redirect_to leave_requests_path, notice: "Leave request submitted."
22
+ # Say it now, not on the payslip: days past the balance are unpaid.
23
+ short = @request.days_beyond_balance
24
+ redirect_to leave_requests_path, notice: "Leave request submitted.#{" #{short.to_s('F')} day(s) are beyond your balance and will be unpaid if approved." if short.positive?}"
23
25
  else
24
26
  @balances = balance_cards
25
27
  render :new, status: :unprocessable_entity
@@ -1,13 +1,13 @@
1
1
  module HrLite
2
- # Employee surface: strictly own PUBLISHED slips — a foreign or
3
- # unpublished id 404s through the scoped relation, never 403s.
2
+ # Employee surface: strictly own RELEASED slips — a foreign, unpublished
3
+ # or not-yet-released id 404s through the scoped relation, never 403s.
4
4
  class SalarySlipsController < ApplicationController
5
5
  def index
6
- @slips = paginate(own_published.recent_first)
6
+ @slips = paginate(own_released.recent_first)
7
7
  end
8
8
 
9
9
  def show
10
- @slip = own_published.find(params[:id])
10
+ @slip = own_released.find(params[:id])
11
11
 
12
12
  respond_to do |format|
13
13
  format.html
@@ -17,8 +17,8 @@ module HrLite
17
17
 
18
18
  private
19
19
 
20
- def own_published
21
- SalarySlip.published.where(user_id: hr_current_user.id)
20
+ def own_released
21
+ SalarySlip.released.where(user_id: hr_current_user.id)
22
22
  end
23
23
 
24
24
  def send_slip_pdf(slip)
@@ -11,7 +11,8 @@ module HrLite
11
11
  # belongs in "More" rather than displacing something everybody uses.
12
12
  # Employee tier all the same — holding an approval IS the authorisation,
13
13
  # so a manager and a stand-in covering for one reach the same screen.
14
- { label: "Approvals", path: :approvals_path, match: [ "/approvals" ] },
14
+ { label: "Approvals", path: :approvals_path, match: [ "/approvals" ],
15
+ if: -> { HrLite::ApprovalFlow.where(active: true).exists? } },
15
16
  { label: "Expenses", path: :expenses_path, match: [ "/expenses" ] },
16
17
  { label: "Benefits", path: :benefits_path, match: [ "/benefits" ] },
17
18
  { label: "Policies", path: :policies_path, match: [ "/policies" ] },
@@ -19,7 +20,8 @@ module HrLite
19
20
  { label: "Org", path: :org_path, match: [ "/org" ] },
20
21
  { label: "Kudos", path: :kudos_path, match: [ "/kudos" ] },
21
22
  { label: "Slips", path: :salary_slips_path, match: [ "/salary_slips" ] },
22
- { label: "Career", path: :career_path, match: [ "/career", "/appraisals", "/profile" ] },
23
+ { label: "Career", path: :career_path, match: [ "/career", "/appraisals" ] },
24
+ { label: "Profile", path: :employee_profile_path, match: [ "/profile", "/resignation" ] },
23
25
  { label: "Documents", path: :documents_path, match: [ "/documents" ] },
24
26
  { label: "ID card", path: :id_card_path, match: [ "/id_card" ] },
25
27
  { label: "Tax", path: :tax_declaration_path, match: [ "/tax_declaration" ] },
@@ -31,15 +33,17 @@ module HrLite
31
33
  { label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
32
34
  { label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
33
35
  { label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
34
- { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ] },
36
+ { label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
37
+ if: -> { hr_can?("expense.approve", scope: :team) } },
35
38
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ] },
36
39
  { label: "Assets", path: :admin_assets_path, match: [ "/admin/assets" ] },
40
+ { label: "Benefits", path: :admin_benefits_path, match: [ "/admin/benefits" ] },
37
41
  { label: "Joining & exits", path: :admin_checklists_path, match: [ "/admin/checklists" ] }
38
42
  ].freeze
39
43
 
40
44
  LEADERSHIP_NAV_ITEMS = [
41
45
  { label: "Employees", path: :admin_employees_path, match: [ "/admin/employees" ] },
42
- { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting" ] },
46
+ { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/setting", "/admin/expense_categories" ] },
43
47
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] },
44
48
  { label: "Policies", path: :admin_policies_path, match: [ "/admin/policies" ] },
45
49
  { label: "Documents", path: :admin_documents_path, match: [ "/admin/documents" ] }
@@ -63,9 +67,11 @@ module HrLite
63
67
  RQRCode::QRCode.new(text).as_svg(use_path: true, viewbox: true, color: "0F172A").html_safe
64
68
  end
65
69
 
66
- # Only items whose routes exist yet (the nav grows with each phase).
70
+ # Only items whose routes exist yet (the nav grows with each phase), and
71
+ # whose optional `if:` holds — a link that always lands on "access
72
+ # denied" or an empty page is worse than no link.
67
73
  def hrl_nav_items(items)
68
- items.select { |item| hr_lite_route?(item[:path]) }
74
+ items.select { |item| hr_lite_route?(item[:path]) && (item[:if].nil? || instance_exec(&item[:if])) }
69
75
  end
70
76
 
71
77
  def hr_lite_route?(helper_name)
@@ -1,7 +1,7 @@
1
1
  module HrLite
2
2
  # Monthly automation: on the 1st, draft + compute the previous month's
3
- # payroll from attendance and the policy, then tell leadership it is
4
- # waiting for review. Publishing stays a deliberate human action —
3
+ # payroll from attendance and the policy, then tell whoever runs payroll
4
+ # (payroll.manage) it is waiting for review — leadership cannot open it. Publishing stays a deliberate human action —
5
5
  # the system prepares, people approve.
6
6
  class PayrollAutoDraftJob < ApplicationJob
7
7
  queue_as :default
@@ -16,10 +16,11 @@ 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
20
  Notifications.publish(
20
21
  "payroll.draft_ready",
21
22
  title: "Payroll #{run.label} computed from attendance — #{run.salary_slips.count} slips await review",
22
- path: "/admin/payroll_runs/#{run.id}"
23
+ path: "/admin/payroll_runs/#{run.id}", bell_to: payroll, email_to: payroll
23
24
  )
24
25
  end
25
26
  end
@@ -0,0 +1,10 @@
1
+ module HrLite
2
+ # Tells employees their slip is ready on config.slip_release_day, when
3
+ # SalarySlip.released first lets them open it. Enqueued by
4
+ # PayrollRun#publish!, which already sent the leadership copy.
5
+ class SlipsReadyJob < ApplicationJob
6
+ def perform(run)
7
+ run.notify_slips_ready(leadership: false)
8
+ end
9
+ end
10
+ end
@@ -14,7 +14,7 @@ module HrLite
14
14
  # leadership audit screen or the policy.changed email.
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
- HrLite::PayrollRun HrLite::SalarySlip
17
+ HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
18
18
  ].freeze
19
19
 
20
20
  scope :recent, -> { order(created_at: :desc) }
@@ -9,7 +9,7 @@ module HrLite
9
9
  belongs_to :user, class_name: HrLite.config.user_class
10
10
 
11
11
  validates :enrolled_on, presence: true
12
- validates :user_id, uniqueness: { scope: :benefit_id }
12
+ validates :user_id, uniqueness: { scope: :benefit_id, message: "is already enrolled" }
13
13
  validates :dependants, numericality: { greater_than_or_equal_to: 0 }
14
14
  validate :ended_after_it_started
15
15
 
@@ -63,7 +63,8 @@ module HrLite
63
63
  # each — and the admin balances grid reads this cell for every employee
64
64
  # times every leave type.
65
65
  calendar = WorkingCalendar.new(range)
66
- requests.sum { |request| LeaveDayCounter.count(request, calendar: calendar) }
66
+ # Loss-of-pay days (beyond paid_days) never drew on the balance.
67
+ requests.sum { |request| [ LeaveDayCounter.count(request, calendar: calendar), request.paid_days ].compact.min }
67
68
  end
68
69
 
69
70
  def available(as_of: Date.current)
@@ -40,6 +40,26 @@ module HrLite
40
40
  leave_type.paid
41
41
  end
42
42
 
43
+ # Days this request would take past the balance — the loss of pay
44
+ # approving it today would fix, or did fix once approved. 0 for unlimited types.
45
+ def days_beyond_balance
46
+ return 0 if leave_type.unlimited?
47
+ return paid_days ? LeaveDayCounter.count(self) - paid_days : 0 if approved?
48
+
49
+ [ LeaveDayCounter.count(self) - covered_by(balance.available(as_of: start_date)), 0 ].max
50
+ end
51
+
52
+ # Unpaid share (0, 0.5 or 1) of `date`: the balance covered the leave's
53
+ # EARLIEST working days (paid_days of them), so the rest are loss of pay.
54
+ def unpaid_on(date)
55
+ return 0 if paid_days.nil?
56
+
57
+ @leave_calendar ||= WorkingCalendar.new(start_date..end_date)
58
+ through = LeaveDayCounter.count_range(start_date: start_date, end_date: date, half_day: half_day,
59
+ calendar: @leave_calendar)
60
+ (through - paid_days).clamp(0, half_day ? BigDecimal("0.5") : 1)
61
+ end
62
+
43
63
  # --- transitions -------------------------------------------------------
44
64
 
45
65
  # With a flow configured this is ONE RUNG: the request becomes `approved`
@@ -47,9 +67,10 @@ module HrLite
47
67
  # the flow is not waiting on, such as HR overriding — the first approval
48
68
  # settles it, exactly as before.
49
69
  #
50
- # Returns false (leaving the request pending) when the balance no longer
51
- # covers it; the re-check runs inside the row lock so two concurrent
52
- # approvals cannot overdraw one balance.
70
+ # Days beyond the balance are approved as loss of pay (paid_days); only
71
+ # comp-off returns false (still pending) when its credit no longer covers
72
+ # it. The balance is read inside the row lock, so two concurrent
73
+ # approvals cannot both spend it.
53
74
  def approve!(actor:, note: nil)
54
75
  return record_routed_decision!(actor, note, :approved) if awaiting?(actor)
55
76
 
@@ -132,15 +153,20 @@ module HrLite
132
153
  def approve_outright!(actor:, note:)
133
154
  insufficient = false
134
155
  transition!("approved", actor, note) do
156
+ next if leave_type.unlimited?
157
+
135
158
  # Serialize on the BALANCE row. `transition!`'s own lock is on this
136
159
  # request, and two pending requests are two different rows — so both
137
160
  # approvals could read the same untouched balance and overdraw it.
138
- LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)) unless leave_type.unlimited?
161
+ # As of start_date: the same date the create-time check uses.
162
+ covered = covered_by(LeaveBalance.lock_for(user, leave_type, LeaveYear.key_for(start_date)).available(as_of: start_date))
163
+ next if LeaveDayCounter.count(self) <= covered
139
164
 
140
- if insufficient_balance_now?
165
+ if leave_type.comp_off
141
166
  insufficient = true
142
167
  raise ActiveRecord::Rollback
143
168
  end
169
+ self.paid_days = covered
144
170
  end
145
171
  return false if insufficient
146
172
 
@@ -195,24 +221,17 @@ module HrLite
195
221
  "type" => leave_type.code,
196
222
  "dates" => date_range_label,
197
223
  "days" => days_count&.to_s("F"),
224
+ "paid_days" => paid_days&.to_s("F"),
198
225
  "note" => note.presence
199
226
  }.compact
200
227
  )
201
228
  end
202
229
 
203
- # This request is still pending here, so balance.used excludes it:
204
- # approving is valid iff the request fits the remaining balance.
205
- def insufficient_balance_now?
206
- return false if leave_type.unlimited?
207
-
208
- # Same as-of date the create-time check used. Reading it as of TODAY
209
- # meant a request validated against the balance it would have on the
210
- # leave dates could never be approved on a monthly-accrual type.
211
- LeaveDayCounter.count(self) > balance.available(as_of: start_date)
212
- end
213
-
214
230
  def recorded_by_hr? = created_by_id.present? && created_by_id != user_id
215
231
 
232
+ # The balance covers whole half-days only, never less than nothing.
233
+ def covered_by(available) = [ (BigDecimal(available.to_s) * 2).floor / BigDecimal(2), 0 ].max
234
+
216
235
  # Recorded leave is approved in the same transaction, so there is nothing
217
236
  # left for a flow to route.
218
237
  def open_approval_route = pending? && super
@@ -310,9 +329,11 @@ module HrLite
310
329
  errors.add(:base, "Attendance is marked in this period") if punched.exists?
311
330
  end
312
331
 
332
+ # Only comp-off is refused past its balance: it is earned credit, never
333
+ # borrowed. Any other type's excess becomes loss of pay at approval.
313
334
  def sufficient_balance
314
335
  return unless start_date && end_date && leave_type
315
- return if leave_type.unlimited? || errors.any?
336
+ return if leave_type.unlimited? || !leave_type.comp_off || errors.any?
316
337
 
317
338
  if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
318
339
  errors.add(:base, "Not enough #{leave_type.name} balance")
@@ -18,6 +18,8 @@ module HrLite
18
18
  validate :period_is_first_of_month
19
19
  validate :amount_is_positive
20
20
  validate :period_is_not_already_paid, on: :create
21
+ # Removing a line from a paid month rewrites a settled payslip just the same.
22
+ before_destroy { throw :abort if paid_month? }
21
23
 
22
24
  scope :for_month, ->(month) { where(period_month: month) }
23
25
 
@@ -47,11 +49,11 @@ module HrLite
47
49
  # TDS projector reads.
48
50
  def period_is_not_already_paid
49
51
  return if user_id.nil? || period_month.nil?
50
-
51
- settled = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
52
- return unless settled
52
+ return unless paid_month?
53
53
 
54
54
  errors.add(:period_month, "has already been paid — put this on the next open month")
55
55
  end
56
+
57
+ def paid_month? = SalarySlip.settled.exists?(user_id: user_id, period_month: period_month)
56
58
  end
57
59
  end
@@ -96,7 +96,23 @@ module HrLite
96
96
  audit!("payroll.published", actor, "slips" => salary_slips.count)
97
97
  end
98
98
 
99
- slips = salary_slips.includes(:user).to_a
99
+ # config.slip_release_day holds the employee notice until the slip opens
100
+ # (SalarySlip.released); leadership still hears at publish.
101
+ day = HrLite.config.slip_release_day
102
+ release_at = day && period_month.next_month.change(day: day).in_time_zone(HrLite.config.time_zone)
103
+ if release_at&.future?
104
+ notify_slips_ready(employees: false)
105
+ SlipsReadyJob.set(wait_until: release_at).perform_later(self)
106
+ else
107
+ notify_slips_ready
108
+ end
109
+ true
110
+ end
111
+
112
+ # "Your salary slip is ready". SlipsReadyJob sends the employee half on
113
+ # the release day, without repeating the leadership copy.
114
+ def notify_slips_ready(employees: true, leadership: true)
115
+ slips = employees ? salary_slips.includes(:user).to_a : []
100
116
  # Everyone is emailed — a final settlement matters most to the person who
101
117
  # has left — but a bell is only useful to someone who can still sign in,
102
118
  # and offboarding revokes that. Sending one pointed at a page they cannot
@@ -111,9 +127,9 @@ module HrLite
111
127
  body: "Open Earthly HR to view or download it.",
112
128
  path: "/salary_slips",
113
129
  bell_to: slips.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
114
- email_to: slips.map(&:user)
130
+ email_to: slips.map(&:user),
131
+ skip_leadership: !leadership
115
132
  )
116
- true
117
133
  end
118
134
 
119
135
  # Ruby-side aggregates (amounts are encrypted — no SQL sums).
@@ -18,6 +18,12 @@ module HrLite
18
18
  before_save :ensure_run_editable
19
19
 
20
20
  scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
21
+ # What an employee may open: published, and with config.slip_release_day
22
+ # only from that day of the next month (day 10: October's slip on 10 Nov).
23
+ scope :released, ->(on: Date.current) {
24
+ day = HrLite.config.slip_release_day
25
+ day ? published.where(period_month: ..(on - (day - 1)).beginning_of_month.prev_month) : published
26
+ }
21
27
  # What counts as already paid for tax-to-date. A finalized run is money
22
28
  # already decided; waiting for publication made the next month's TDS
23
29
  # projection read those earnings as zero.