hr_lite 0.1.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 (176) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +3 -0
  6. data/app/assets/javascripts/hr_lite/geo_punch.js +61 -0
  7. data/app/assets/javascripts/hr_lite/mention.js +100 -0
  8. data/app/assets/stylesheets/hr_lite/application.css +15 -0
  9. data/app/assets/stylesheets/hr_lite/hr_lite.css +239 -0
  10. data/app/controllers/hr_lite/admin/appraisals_controller.rb +74 -0
  11. data/app/controllers/hr_lite/admin/attendances_controller.rb +79 -0
  12. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +9 -0
  13. data/app/controllers/hr_lite/admin/base_controller.rb +16 -0
  14. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +33 -0
  15. data/app/controllers/hr_lite/admin/employees_controller.rb +53 -0
  16. data/app/controllers/hr_lite/admin/holidays_controller.rb +70 -0
  17. data/app/controllers/hr_lite/admin/leadership_controller.rb +18 -0
  18. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +41 -0
  19. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +41 -0
  20. data/app/controllers/hr_lite/admin/leave_types_controller.rb +54 -0
  21. data/app/controllers/hr_lite/admin/office_locations_controller.rb +46 -0
  22. data/app/controllers/hr_lite/admin/overview_controller.rb +12 -0
  23. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +93 -0
  24. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +44 -0
  25. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +49 -0
  26. data/app/controllers/hr_lite/admin/settings_controller.rb +18 -0
  27. data/app/controllers/hr_lite/application_controller.rb +76 -0
  28. data/app/controllers/hr_lite/appraisals_controller.rb +18 -0
  29. data/app/controllers/hr_lite/attendance_controller.rb +37 -0
  30. data/app/controllers/hr_lite/calendar_controller.rb +20 -0
  31. data/app/controllers/hr_lite/career_controller.rb +9 -0
  32. data/app/controllers/hr_lite/employee_profiles_controller.rb +9 -0
  33. data/app/controllers/hr_lite/holidays_controller.rb +9 -0
  34. data/app/controllers/hr_lite/home_controller.rb +7 -0
  35. data/app/controllers/hr_lite/kudos_controller.rb +36 -0
  36. data/app/controllers/hr_lite/leave_balances_controller.rb +11 -0
  37. data/app/controllers/hr_lite/leave_requests_controller.rb +56 -0
  38. data/app/controllers/hr_lite/salary_slips_controller.rb +39 -0
  39. data/app/controllers/hr_lite/users_controller.rb +9 -0
  40. data/app/helpers/hr_lite/application_helper.rb +105 -0
  41. data/app/jobs/hr_lite/application_job.rb +4 -0
  42. data/app/jobs/hr_lite/daily_digest_job.rb +33 -0
  43. data/app/jobs/hr_lite/leave_year_rollover_job.rb +29 -0
  44. data/app/mailers/hr_lite/application_mailer.rb +5 -0
  45. data/app/mailers/hr_lite/event_mailer.rb +33 -0
  46. data/app/models/concerns/hr_lite/audited.rb +70 -0
  47. data/app/models/concerns/hr_lite/encrypted_money.rb +32 -0
  48. data/app/models/hr_lite/application_record.rb +5 -0
  49. data/app/models/hr_lite/appraisal.rb +89 -0
  50. data/app/models/hr_lite/attendance_record.rb +41 -0
  51. data/app/models/hr_lite/audit_log.rb +17 -0
  52. data/app/models/hr_lite/designation_change.rb +52 -0
  53. data/app/models/hr_lite/employee_profile.rb +72 -0
  54. data/app/models/hr_lite/holiday.rb +18 -0
  55. data/app/models/hr_lite/kudo.rb +54 -0
  56. data/app/models/hr_lite/kudo_mention.rb +8 -0
  57. data/app/models/hr_lite/leave_balance.rb +42 -0
  58. data/app/models/hr_lite/leave_request.rb +207 -0
  59. data/app/models/hr_lite/leave_type.rb +22 -0
  60. data/app/models/hr_lite/office_location.rb +21 -0
  61. data/app/models/hr_lite/payroll_run.rb +112 -0
  62. data/app/models/hr_lite/salary_slip.rb +78 -0
  63. data/app/models/hr_lite/salary_structure.rb +49 -0
  64. data/app/models/hr_lite/setting.rb +14 -0
  65. data/app/services/hr_lite/attendance_puncher.rb +103 -0
  66. data/app/services/hr_lite/attendance_summary.rb +98 -0
  67. data/app/services/hr_lite/calculators/esi.rb +25 -0
  68. data/app/services/hr_lite/calculators/pf.rb +32 -0
  69. data/app/services/hr_lite/calculators/professional_tax.rb +20 -0
  70. data/app/services/hr_lite/calculators/proration.rb +30 -0
  71. data/app/services/hr_lite/calculators/tds.rb +74 -0
  72. data/app/services/hr_lite/day_status.rb +49 -0
  73. data/app/services/hr_lite/leave_day_counter.rb +24 -0
  74. data/app/services/hr_lite/overview_query.rb +39 -0
  75. data/app/services/hr_lite/payroll_run_processor.rb +46 -0
  76. data/app/services/hr_lite/pdf_renderer.rb +23 -0
  77. data/app/services/hr_lite/slip_builder.rb +106 -0
  78. data/app/services/hr_lite/working_calendar.rb +41 -0
  79. data/app/views/hr_lite/admin/appraisals/_form.html.erb +29 -0
  80. data/app/views/hr_lite/admin/appraisals/edit.html.erb +6 -0
  81. data/app/views/hr_lite/admin/appraisals/new.html.erb +6 -0
  82. data/app/views/hr_lite/admin/attendances/index.html.erb +49 -0
  83. data/app/views/hr_lite/admin/attendances/show.html.erb +55 -0
  84. data/app/views/hr_lite/admin/audit_logs/index.html.erb +39 -0
  85. data/app/views/hr_lite/admin/designation_changes/new.html.erb +19 -0
  86. data/app/views/hr_lite/admin/employees/_form.html.erb +40 -0
  87. data/app/views/hr_lite/admin/employees/edit.html.erb +3 -0
  88. data/app/views/hr_lite/admin/employees/index.html.erb +40 -0
  89. data/app/views/hr_lite/admin/employees/new.html.erb +3 -0
  90. data/app/views/hr_lite/admin/employees/show.html.erb +75 -0
  91. data/app/views/hr_lite/admin/holidays/index.html.erb +59 -0
  92. data/app/views/hr_lite/admin/leave_balances/index.html.erb +58 -0
  93. data/app/views/hr_lite/admin/leave_requests/index.html.erb +35 -0
  94. data/app/views/hr_lite/admin/leave_requests/show.html.erb +39 -0
  95. data/app/views/hr_lite/admin/leave_types/_form.html.erb +27 -0
  96. data/app/views/hr_lite/admin/leave_types/edit.html.erb +10 -0
  97. data/app/views/hr_lite/admin/leave_types/index.html.erb +39 -0
  98. data/app/views/hr_lite/admin/leave_types/new.html.erb +3 -0
  99. data/app/views/hr_lite/admin/office_locations/_form.html.erb +18 -0
  100. data/app/views/hr_lite/admin/office_locations/edit.html.erb +10 -0
  101. data/app/views/hr_lite/admin/office_locations/index.html.erb +30 -0
  102. data/app/views/hr_lite/admin/office_locations/new.html.erb +3 -0
  103. data/app/views/hr_lite/admin/overview/index.html.erb +72 -0
  104. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +33 -0
  105. data/app/views/hr_lite/admin/payroll_runs/new.html.erb +20 -0
  106. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +76 -0
  107. data/app/views/hr_lite/admin/salary_slips/show.html.erb +42 -0
  108. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +25 -0
  109. data/app/views/hr_lite/admin/salary_structures/edit.html.erb +6 -0
  110. data/app/views/hr_lite/admin/salary_structures/new.html.erb +6 -0
  111. data/app/views/hr_lite/admin/settings/edit.html.erb +18 -0
  112. data/app/views/hr_lite/appraisals/index.html.erb +22 -0
  113. data/app/views/hr_lite/appraisals/show.html.erb +32 -0
  114. data/app/views/hr_lite/attendance/_month_grid.html.erb +41 -0
  115. data/app/views/hr_lite/attendance/_punch_card.html.erb +41 -0
  116. data/app/views/hr_lite/attendance/show.html.erb +22 -0
  117. data/app/views/hr_lite/calendar/show.html.erb +45 -0
  118. data/app/views/hr_lite/career/show.html.erb +49 -0
  119. data/app/views/hr_lite/employee_profiles/show.html.erb +22 -0
  120. data/app/views/hr_lite/event_mailer/event.html.erb +10 -0
  121. data/app/views/hr_lite/event_mailer/event.text.erb +10 -0
  122. data/app/views/hr_lite/event_mailer/leadership.html.erb +27 -0
  123. data/app/views/hr_lite/event_mailer/leadership.text.erb +16 -0
  124. data/app/views/hr_lite/holidays/_next_holiday.html.erb +12 -0
  125. data/app/views/hr_lite/holidays/index.html.erb +30 -0
  126. data/app/views/hr_lite/home/index.html.erb +25 -0
  127. data/app/views/hr_lite/kudos/_kudo.html.erb +17 -0
  128. data/app/views/hr_lite/kudos/index.html.erb +39 -0
  129. data/app/views/hr_lite/leave_balances/index.html.erb +19 -0
  130. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +17 -0
  131. data/app/views/hr_lite/leave_requests/index.html.erb +37 -0
  132. data/app/views/hr_lite/leave_requests/new.html.erb +36 -0
  133. data/app/views/hr_lite/leave_requests/show.html.erb +25 -0
  134. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +48 -0
  135. data/app/views/hr_lite/salary_slips/index.html.erb +29 -0
  136. data/app/views/hr_lite/salary_slips/pdf.html.erb +72 -0
  137. data/app/views/hr_lite/salary_slips/show.html.erb +12 -0
  138. data/app/views/hr_lite/shared/_form_errors.html.erb +10 -0
  139. data/app/views/hr_lite/shared/_pagination.html.erb +11 -0
  140. data/app/views/layouts/hr_lite/application.html.erb +56 -0
  141. data/app/views/layouts/hr_lite/mailer.html.erb +30 -0
  142. data/app/views/layouts/hr_lite/mailer.text.erb +6 -0
  143. data/app/views/layouts/hr_lite/pdf.html.erb +26 -0
  144. data/config/routes.rb +57 -0
  145. data/db/migrate/20260718190726_create_hr_lite_kudos.rb +14 -0
  146. data/db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb +12 -0
  147. data/db/migrate/20260718190728_create_hr_lite_audit_logs.rb +15 -0
  148. data/db/migrate/20260718192342_create_hr_lite_office_locations.rb +13 -0
  149. data/db/migrate/20260718192343_create_hr_lite_attendance_records.rb +27 -0
  150. data/db/migrate/20260718193045_create_hr_lite_holidays.rb +14 -0
  151. data/db/migrate/20260718193046_create_hr_lite_leave_types.rb +20 -0
  152. data/db/migrate/20260718193047_create_hr_lite_leave_balances.rb +17 -0
  153. data/db/migrate/20260718193048_create_hr_lite_leave_requests.rb +23 -0
  154. data/db/migrate/20260718193049_create_hr_lite_settings.rb +10 -0
  155. data/db/migrate/20260718194534_create_hr_lite_employee_profiles.rb +27 -0
  156. data/db/migrate/20260718194535_create_hr_lite_salary_structures.rb +23 -0
  157. data/db/migrate/20260718194536_create_hr_lite_payroll_runs.rb +20 -0
  158. data/db/migrate/20260718194537_create_hr_lite_salary_slips.rb +28 -0
  159. data/db/migrate/20260718195953_create_hr_lite_appraisals.rb +22 -0
  160. data/db/migrate/20260718195954_create_hr_lite_designation_changes.rb +17 -0
  161. data/lib/generators/hr_lite/install/install_generator.rb +29 -0
  162. data/lib/generators/hr_lite/install/templates/AFTER_INSTALL +35 -0
  163. data/lib/generators/hr_lite/install/templates/initializer.rb +45 -0
  164. data/lib/hr_lite/configuration.rb +60 -0
  165. data/lib/hr_lite/current.rb +7 -0
  166. data/lib/hr_lite/engine.rb +39 -0
  167. data/lib/hr_lite/geo.rb +22 -0
  168. data/lib/hr_lite/mention_parser.rb +18 -0
  169. data/lib/hr_lite/money.rb +33 -0
  170. data/lib/hr_lite/notifications.rb +99 -0
  171. data/lib/hr_lite/seeds.rb +42 -0
  172. data/lib/hr_lite/statutory_rate_card.rb +60 -0
  173. data/lib/hr_lite/version.rb +3 -0
  174. data/lib/hr_lite.rb +104 -0
  175. data/lib/tasks/hr_lite_tasks.rake +8 -0
  176. metadata +250 -0
@@ -0,0 +1,98 @@
1
+ module HrLite
2
+ # Month roll-up per user — the payroll contract. Exact math per date:
3
+ #
4
+ # holiday / weekend / present / paid full-day leave -> payable +1
5
+ # unpaid full-day leave (LWP) / absent -> lop +1
6
+ # half-day punch (no leave) -> payable +0.5, lop +0.5
7
+ # half-day PAID leave: leave half payable; other half payable if
8
+ # punched, else lop (unpaid half-day leave: leave half is lop too)
9
+ # future dates -> upcoming (neither)
10
+ # dates outside the employment window (before DOJ / after exit, when an
11
+ # EmployeeProfile exists) -> out_of_window (neither)
12
+ #
13
+ # The window lives HERE and only here — payroll consumes payable/lop as-is
14
+ # and never re-clips, so a mid-month joiner is never double-penalized.
15
+ # For closed months: payable + lop + upcoming + out_of_window == days_in_month.
16
+ module AttendanceSummary
17
+ def self.for(user:, month:)
18
+ range = month.beginning_of_month..month.end_of_month
19
+ profile = EmployeeProfile.find_by(user_id: user.id)
20
+ from_day_status(DayStatus.new(user: user, range: range), range, profile: profile)
21
+ end
22
+
23
+ # Batch variant for team/payroll screens: 3 queries total, not 3×N.
24
+ def self.for_all(users:, month:)
25
+ users.index_with { |user| self.for(user: user, month: month) }.transform_keys(&:id)
26
+ end
27
+
28
+ def self.from_day_status(day_status, range, profile: nil)
29
+ summary = {
30
+ present: 0.to_d, half_day: 0.to_d, paid_leave: 0.to_d, unpaid_leave: 0.to_d,
31
+ holiday: 0, weekend: 0, absent: 0.to_d, upcoming: 0, out_of_window: 0,
32
+ payable_days: 0.to_d, lop_days: 0.to_d, days_in_month: range.count
33
+ }
34
+
35
+ range.each do |date|
36
+ if profile && !profile.active_on?(date)
37
+ summary[:out_of_window] += 1
38
+ next
39
+ end
40
+
41
+ day = day_status.for(date)
42
+ case day.kind
43
+ when :holiday
44
+ summary[:holiday] += 1
45
+ summary[:payable_days] += 1
46
+ when :weekend
47
+ summary[:weekend] += 1
48
+ summary[:payable_days] += 1
49
+ when :present
50
+ summary[:present] += 1
51
+ summary[:payable_days] += 1
52
+ when :half_day
53
+ summary[:half_day] += 1
54
+ summary[:payable_days] += BigDecimal("0.5")
55
+ summary[:lop_days] += BigDecimal("0.5")
56
+ when :leave
57
+ if day.leave.paid?
58
+ summary[:paid_leave] += 1
59
+ summary[:payable_days] += 1
60
+ else
61
+ summary[:unpaid_leave] += 1
62
+ summary[:lop_days] += 1
63
+ end
64
+ when :half_day_leave
65
+ apply_half_day_leave(summary, day)
66
+ when :upcoming
67
+ summary[:upcoming] += 1
68
+ else # :absent
69
+ summary[:absent] += 1
70
+ summary[:lop_days] += 1
71
+ end
72
+ end
73
+
74
+ summary
75
+ end
76
+
77
+ def self.apply_half_day_leave(summary, day)
78
+ half = BigDecimal("0.5")
79
+
80
+ if day.leave.paid?
81
+ summary[:paid_leave] += half
82
+ summary[:payable_days] += half
83
+ else
84
+ summary[:unpaid_leave] += half
85
+ summary[:lop_days] += half
86
+ end
87
+
88
+ if day.record&.check_in_at
89
+ summary[:present] += half
90
+ summary[:payable_days] += half
91
+ else
92
+ summary[:absent] += half
93
+ summary[:lop_days] += half
94
+ end
95
+ end
96
+ private_class_method :apply_half_day_leave
97
+ end
98
+ end
@@ -0,0 +1,25 @@
1
+ module HrLite
2
+ module Calculators
3
+ # ESI: eligibility is decided on the FULL structure monthly gross
4
+ # (a low-attendance month must not pull someone into ESI); when
5
+ # eligible, contributions apply to the earned gross and round UP
6
+ # to the next rupee (ESIC rule).
7
+ module Esi
8
+ Result = Struct.new(:applicable, :employee, :employer, keyword_init: true) do
9
+ def applicable? = applicable
10
+ end
11
+
12
+ def self.call(monthly_gross:, gross_earned:, applicable:, rates:)
13
+ eligible = applicable && Money.d(monthly_gross) <= rates[:gross_ceiling]
14
+ return Result.new(applicable: false, employee: BigDecimal(0), employer: BigDecimal(0)) unless eligible
15
+
16
+ earned = Money.d(gross_earned)
17
+ Result.new(
18
+ applicable: true,
19
+ employee: Money.ceil_rupee(earned * rates[:employee_rate]),
20
+ employer: Money.ceil_rupee(earned * rates[:employer_rate])
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ module HrLite
2
+ module Calculators
3
+ # Provident fund on earned basic. Employee 12%; employer 12% split into
4
+ # EPS (8.33%, wage-capped) and EPF (remainder); EDLI + admin charges are
5
+ # employer-cost lines. All contributions round to the nearest rupee.
6
+ module Pf
7
+ Result = Struct.new(:pf_wage, :employee, :employer_eps, :employer_epf,
8
+ :edli, :admin_charges, keyword_init: true)
9
+
10
+ def self.call(basic_earned:, on_full_basic:, rates:)
11
+ basic = Money.d(basic_earned)
12
+ pf_wage = on_full_basic ? basic : [ basic, rates[:wage_ceiling] ].min
13
+
14
+ employee = Money.round_rupee(pf_wage * rates[:employee_rate])
15
+ employer_total = Money.round_rupee(pf_wage * rates[:employer_rate])
16
+ eps_wage = [ pf_wage, rates[:eps_wage_ceiling] ].min
17
+ eps = Money.round_rupee(eps_wage * rates[:eps_rate])
18
+ eps = employer_total if eps > employer_total
19
+ edli_wage = [ pf_wage, rates[:edli_ceiling] ].min
20
+
21
+ Result.new(
22
+ pf_wage: pf_wage,
23
+ employee: employee,
24
+ employer_eps: eps,
25
+ employer_epf: employer_total - eps,
26
+ edli: Money.round_rupee(edli_wage * rates[:edli_rate]),
27
+ admin_charges: Money.round_rupee(pf_wage * rates[:admin_rate])
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ module HrLite
2
+ module Calculators
3
+ # State-slab professional tax on earned gross. Unknown states and
4
+ # states without PT (UP, Uttarakhand) simply yield zero. Slabs may
5
+ # carry a feb_extra for states that top up the February deduction.
6
+ module ProfessionalTax
7
+ def self.call(state:, gross_earned:, period_month:, rates:)
8
+ slabs = rates[state.to_s] || []
9
+ earned = Money.d(gross_earned)
10
+
11
+ slab = slabs.reverse.find { |row| earned > row[:above] }
12
+ return BigDecimal(0) unless slab
13
+
14
+ amount = slab[:monthly]
15
+ amount += slab[:feb_extra] if slab[:feb_extra] && period_month.month == 2
16
+ Money.round_rupee(amount)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ module HrLite
2
+ module Calculators
3
+ # Calendar-day proration: each structure component earns
4
+ # full × payable/days_in_month, rounded to paise per line.
5
+ module Proration
6
+ COMPONENTS = [
7
+ [ "basic", "Basic" ],
8
+ [ "hra", "HRA" ],
9
+ [ "special_allowance", "Special allowance" ],
10
+ [ "other_earnings", "Other earnings" ]
11
+ ].freeze
12
+
13
+ def self.call(structure:, payable_days:, days_in_month:)
14
+ factor = Money.d(payable_days) / Money.d(days_in_month)
15
+
16
+ COMPONENTS.filter_map do |attribute, label|
17
+ full = structure.public_send(attribute)
18
+ next if full.nil? || full.zero?
19
+
20
+ {
21
+ code: attribute,
22
+ label: label,
23
+ full_amount: Money.round2(full),
24
+ amount: Money.round2(full * factor)
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ module HrLite
2
+ module Calculators
3
+ # Projection-grade TDS (not Form-16-grade — no surcharge, perquisites or
4
+ # HRA-exemption math; the per-slip override is the escape hatch and the
5
+ # slip shows the whole working so "why is my TDS X" answers itself).
6
+ #
7
+ # projected annual gross = FY gross paid so far + this month
8
+ # + structure gross × months remaining after this one
9
+ # taxable = projected − standard deduction − (old regime: declared deductions)
10
+ # annual = slab tax − 87A rebate (full when taxable ≤ cap) + 4% cess, §288B rounded
11
+ # monthly = max((annual − TDS already deducted) / months remaining incl. this, 0)
12
+ module Tds
13
+ Result = Struct.new(:monthly, :projected_annual_gross, :taxable, :annual_tax,
14
+ :details, keyword_init: true)
15
+
16
+ HIGH_INCOME_WARNING_THRESHOLD = BigDecimal("5000000")
17
+
18
+ def self.call(regime:, structure_monthly_gross:, gross_earned_this_month:,
19
+ fy_gross_paid:, fy_tds_paid:, months_remaining:,
20
+ declared_annual_deductions:, rates:, override: nil)
21
+ table = rates[regime.to_s] || rates["new"]
22
+
23
+ if override.present?
24
+ monthly = Money.round_rupee(override)
25
+ return Result.new(monthly: monthly, projected_annual_gross: nil, taxable: nil, annual_tax: nil,
26
+ details: { "regime" => regime.to_s, "override" => monthly.to_s("F") })
27
+ end
28
+
29
+ months_remaining = [ months_remaining.to_i, 1 ].max
30
+ projected = Money.d(fy_gross_paid) + Money.d(gross_earned_this_month) +
31
+ (Money.d(structure_monthly_gross) * (months_remaining - 1))
32
+
33
+ deductions = table[:standard_deduction]
34
+ deductions += Money.d(declared_annual_deductions) if regime.to_s == "old"
35
+ taxable = [ projected - deductions, BigDecimal(0) ].max
36
+
37
+ slab_tax = slab_tax_for(taxable, table[:slabs])
38
+ slab_tax = BigDecimal(0) if taxable <= table[:rebate_cap] # §87A full rebate
39
+ annual = Money.round_to_10(slab_tax * (1 + table[:cess_rate]))
40
+
41
+ monthly = Money.round_rupee((annual - Money.d(fy_tds_paid)) / months_remaining)
42
+ monthly = BigDecimal(0) if monthly.negative?
43
+
44
+ Result.new(
45
+ monthly: monthly,
46
+ projected_annual_gross: Money.round2(projected),
47
+ taxable: Money.round2(taxable),
48
+ annual_tax: annual,
49
+ details: {
50
+ "regime" => regime.to_s,
51
+ "projected_annual_gross" => Money.round2(projected).to_s("F"),
52
+ "standard_deduction" => table[:standard_deduction].to_s("F"),
53
+ "declared_deductions" => (regime.to_s == "old" ? Money.d(declared_annual_deductions).to_s("F") : "0"),
54
+ "taxable" => Money.round2(taxable).to_s("F"),
55
+ "annual_tax_with_cess" => annual.to_s("F"),
56
+ "fy_tds_already_deducted" => Money.d(fy_tds_paid).to_s("F"),
57
+ "months_remaining" => months_remaining.to_s,
58
+ "high_income_review" => (taxable > HIGH_INCOME_WARNING_THRESHOLD).to_s
59
+ }
60
+ )
61
+ end
62
+
63
+ def self.slab_tax_for(taxable, slabs)
64
+ slabs.sum(BigDecimal(0)) do |lower, upper, rate|
65
+ next BigDecimal(0) if taxable <= lower
66
+
67
+ span_top = upper && taxable > upper ? upper : taxable
68
+ (span_top - lower) * rate
69
+ end
70
+ end
71
+ private_class_method :slab_tax_for
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,49 @@
1
+ module HrLite
2
+ # Resolves what each date "is" for a user, with the full precedence chain:
3
+ #
4
+ # holiday > weekend > approved full-day leave > punch > absent/upcoming
5
+ #
6
+ # A half-day leave coexists with a punch (kind :half_day_leave, record
7
+ # exposed); a punch on a holiday keeps :holiday but exposes the record
8
+ # (UI shows a "worked" hint). One resolver feeds the month grids, the
9
+ # overview board and the attendance summary — a single truth.
10
+ class DayStatus
11
+ Day = Struct.new(:kind, :record, :leave, keyword_init: true)
12
+
13
+ def initialize(user:, range:)
14
+ @range = range
15
+ @records = AttendanceRecord.where(user_id: user.id, date: range).index_by(&:date)
16
+ @calendar = WorkingCalendar.new(range)
17
+ @leaves = LeaveRequest.approved.includes(:leave_type)
18
+ .where(user_id: user.id)
19
+ .overlapping_range(range.first, range.last).to_a
20
+ end
21
+
22
+ attr_reader :calendar
23
+
24
+ def for(date)
25
+ record = @records[date]
26
+ leave = @leaves.find { |l| l.start_date <= date && l.end_date >= date }
27
+
28
+ return Day.new(kind: :holiday, record: record, leave: leave) if @calendar.holiday?(date)
29
+ return Day.new(kind: :weekend, record: record, leave: leave) if @calendar.weekend?(date)
30
+
31
+ if leave
32
+ kind = leave.half_day ? :half_day_leave : :leave
33
+ return Day.new(kind: kind, record: record, leave: leave)
34
+ end
35
+
36
+ if record&.check_in_at
37
+ Day.new(kind: record.status.to_sym, record: record)
38
+ elsif date > Date.current
39
+ Day.new(kind: :upcoming, record: nil)
40
+ else
41
+ Day.new(kind: :absent, record: record)
42
+ end
43
+ end
44
+
45
+ def counts
46
+ @range.each_with_object(Hash.new(0)) { |date, acc| acc[self.for(date).kind] += 1 }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ module HrLite
2
+ # How many working days a leave request actually consumes. Weekends and
3
+ # company-wide holidays inside the range are free; a half-day (single-day
4
+ # requests only) consumes 0.5.
5
+ module LeaveDayCounter
6
+ def self.count(leave_request)
7
+ count_range(
8
+ start_date: leave_request.start_date,
9
+ end_date: leave_request.end_date,
10
+ half_day: leave_request.half_day
11
+ )
12
+ end
13
+
14
+ def self.count_range(start_date:, end_date:, half_day: false)
15
+ return 0 if start_date.nil? || end_date.nil? || end_date < start_date
16
+
17
+ range = start_date..end_date
18
+ working = WorkingCalendar.new(range).working_days_in(range)
19
+ return BigDecimal("0.5") if half_day && working.positive?
20
+
21
+ BigDecimal(working)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ module HrLite
2
+ # Feeds both the admin overview board and the daily leadership digest —
3
+ # one source, so they always agree. Sections return Relations (callers
4
+ # paginate or cap as they see fit).
5
+ class OverviewQuery
6
+ def initialize(date: Date.current)
7
+ @date = date
8
+ end
9
+
10
+ def pending_requests
11
+ LeaveRequest.pending.includes(:leave_type, :user).order(:created_at)
12
+ end
13
+
14
+ def on_leave_today
15
+ LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date)
16
+ end
17
+
18
+ def flagged_today
19
+ AttendanceRecord.for_date(@date).flagged.includes(:user)
20
+ end
21
+
22
+ def missing_checkout_yesterday
23
+ AttendanceRecord.for_date(@date - 1).missing_checkout.includes(:user)
24
+ end
25
+
26
+ def kpis
27
+ {
28
+ pending: pending_requests.count,
29
+ on_leave: on_leave_today.count,
30
+ flagged: flagged_today.count,
31
+ missing_checkout: missing_checkout_yesterday.count
32
+ }
33
+ end
34
+
35
+ def empty?
36
+ kpis.values.all?(&:zero?)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ module HrLite
2
+ # (Re)computes every slip for a run in one transaction. Missing structures
3
+ # become warnings, manual overrides survive recomputes, employees who
4
+ # became ineligible lose their draft slips.
5
+ class PayrollRunProcessor
6
+ def self.call(run)
7
+ new(run).call
8
+ end
9
+
10
+ def initialize(run)
11
+ @run = run
12
+ end
13
+
14
+ def call
15
+ warnings = []
16
+
17
+ ActiveRecord::Base.transaction do
18
+ eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
19
+ keep_ids = []
20
+
21
+ eligible.each do |profile|
22
+ user = profile.user
23
+ structure = SalaryStructure.effective_for(user, @run.period_month)
24
+ if structure.nil?
25
+ warnings << "No salary structure for #{HrLite.display_name(user)} — skipped"
26
+ next
27
+ end
28
+
29
+ slip = @run.salary_slips.find_or_initialize_by(user_id: user.id)
30
+ attributes = SlipBuilder.call(
31
+ run: @run, user: user, structure: structure, profile: profile,
32
+ lop_override: slip.lop_override, tds_override: slip.tds_override
33
+ )
34
+ slip.assign_attributes(attributes)
35
+ slip.save!
36
+ keep_ids << slip.id
37
+ end
38
+
39
+ @run.salary_slips.where.not(id: keep_ids).destroy_all
40
+ @run.update!(warnings: warnings)
41
+ end
42
+
43
+ @run
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ module HrLite
2
+ # Slip PDF entry point. Host override first (earthly_vms plugs its cached
3
+ # WickedPdf service in via config.render_pdf); otherwise a built-in
4
+ # WickedPdf render when the gem is present; otherwise nil — the controller
5
+ # explains that PDF is not configured rather than crashing.
6
+ module PdfRenderer
7
+ def self.render(template:, assigns:, cache_key: nil)
8
+ if HrLite.config.render_pdf
9
+ HrLite.config.render_pdf.call(template: template, assigns: assigns, cache_key: cache_key)
10
+ elsif wicked_pdf_class
11
+ html = HrLite::ApplicationController.render(
12
+ template: template, assigns: assigns, layout: "hr_lite/pdf", formats: [ :html ]
13
+ )
14
+ wicked_pdf_class.new.pdf_from_string(html, page_size: "A4")
15
+ end
16
+ end
17
+
18
+ # Seam for tests and for hosts that lazy-load wicked_pdf.
19
+ def self.wicked_pdf_class
20
+ defined?(WickedPdf) ? WickedPdf : nil
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,106 @@
1
+ module HrLite
2
+ # Assembles one employee's slip numbers for a run. Sequence: attendance
3
+ # summary -> proration -> PF -> ESI -> PT -> TDS -> totals. Every line is
4
+ # rounded by its own statutory rule exactly once; net pay is a plain
5
+ # subtraction of already-rounded lines, so nothing can drift.
6
+ class SlipBuilder
7
+ def self.call(run:, user:, structure:, profile:, lop_override: nil, tds_override: nil)
8
+ new(run, user, structure, profile, lop_override, tds_override).call
9
+ end
10
+
11
+ def initialize(run, user, structure, profile, lop_override, tds_override)
12
+ @run = run
13
+ @user = user
14
+ @structure = structure
15
+ @profile = profile
16
+ @lop_override = lop_override
17
+ @tds_override = tds_override
18
+ @rates = StatutoryRateCard.for(run.period_month)
19
+ end
20
+
21
+ def call
22
+ summary = AttendanceSummary.for(user: @user, month: @run.period_month)
23
+ days_in_month = summary[:days_in_month]
24
+ lop = Money.d(@lop_override || summary[:lop_days])
25
+ payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) - lop, Money.d(0) ].max
26
+
27
+ earnings = Calculators::Proration.call(
28
+ structure: @structure, payable_days: payable, days_in_month: days_in_month
29
+ )
30
+ gross_earned = earnings.sum(BigDecimal(0)) { |row| row[:amount] }
31
+ basic_earned = earnings.find { |row| row[:code] == "basic" }&.fetch(:amount) || BigDecimal(0)
32
+
33
+ deductions = []
34
+ employer_costs = {}
35
+
36
+ if @structure.pf_applicable
37
+ pf = Calculators::Pf.call(basic_earned: basic_earned,
38
+ on_full_basic: @structure.pf_on_full_basic, rates: @rates[:pf])
39
+ deductions << { code: "pf_employee", label: "Provident fund", amount: pf.employee,
40
+ meta: { pf_wage: pf.pf_wage.to_s("F") } }
41
+ employer_costs.merge!(
42
+ pf_eps: pf.employer_eps, pf_epf: pf.employer_epf,
43
+ edli: pf.edli, pf_admin: pf.admin_charges
44
+ )
45
+ end
46
+
47
+ esi = Calculators::Esi.call(monthly_gross: @structure.monthly_gross, gross_earned: gross_earned,
48
+ applicable: @structure.esi_applicable, rates: @rates[:esi])
49
+ if esi.applicable?
50
+ deductions << { code: "esi_employee", label: "ESI", amount: esi.employee }
51
+ employer_costs[:esi_employer] = esi.employer
52
+ end
53
+
54
+ pt = Calculators::ProfessionalTax.call(state: @structure.pt_state, gross_earned: gross_earned,
55
+ period_month: @run.period_month, rates: @rates[:pt])
56
+ deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive?
57
+
58
+ fy = SalarySlip.fy_to_date(@user, @run.period_month)
59
+ tds = Calculators::Tds.call(
60
+ regime: @profile.tax_regime,
61
+ structure_monthly_gross: @structure.monthly_gross,
62
+ gross_earned_this_month: gross_earned,
63
+ fy_gross_paid: fy[:gross],
64
+ fy_tds_paid: fy[:tds],
65
+ months_remaining: months_remaining_in_fy,
66
+ declared_annual_deductions: @profile.declared_annual_deductions,
67
+ rates: @rates[:income_tax],
68
+ override: @tds_override
69
+ )
70
+ deductions << { code: "tds", label: "Income tax (TDS)", amount: tds.monthly } if tds.monthly.positive?
71
+
72
+ total_deductions = deductions.sum(BigDecimal(0)) { |row| row[:amount] }
73
+
74
+ {
75
+ period_month: @run.period_month,
76
+ days_in_month: days_in_month,
77
+ payable_days: payable,
78
+ lop_days: Money.d(summary[:lop_days]),
79
+ lop_override: @lop_override,
80
+ tds_override: @tds_override,
81
+ earnings: serialize_rows(earnings),
82
+ deductions: serialize_rows(deductions),
83
+ employer_costs: employer_costs.transform_values { |v| v.to_s("F") }.to_json,
84
+ tax_details: tds.details.to_json,
85
+ gross_earnings: gross_earned,
86
+ total_deductions: total_deductions,
87
+ net_pay: gross_earned - total_deductions,
88
+ computed_at: Time.current
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ # Months left in the Indian FY including the run month itself.
95
+ def months_remaining_in_fy
96
+ month = @run.period_month.month
97
+ month >= 4 ? (15 - month) : (3 - month + 1)
98
+ end
99
+
100
+ def serialize_rows(rows)
101
+ rows.map { |row|
102
+ row.transform_values { |value| value.is_a?(BigDecimal) ? value.to_s("F") : value }
103
+ }.to_json
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ module HrLite
2
+ # The single source of day classification. Preloads holidays and the
3
+ # weekend policy once per instance; every other service (leave counting,
4
+ # day status, attendance summary) delegates here.
5
+ class WorkingCalendar
6
+ def initialize(range)
7
+ @range = range
8
+ @holiday_dates = Holiday.dates_for(range)
9
+ @policy = Setting.instance.weekend_policy
10
+ end
11
+
12
+ def holiday?(date)
13
+ @holiday_dates.include?(date)
14
+ end
15
+
16
+ def weekend?(date)
17
+ case @policy
18
+ when "sun_only"
19
+ date.sunday?
20
+ when "second_fourth_sat_sun"
21
+ date.sunday? || (date.saturday? && [ 2, 4 ].include?(week_of_month(date)))
22
+ else # sat_sun
23
+ date.saturday? || date.sunday?
24
+ end
25
+ end
26
+
27
+ def working_day?(date)
28
+ !holiday?(date) && !weekend?(date)
29
+ end
30
+
31
+ def working_days_in(range)
32
+ range.count { |date| working_day?(date) }
33
+ end
34
+
35
+ private
36
+
37
+ def week_of_month(date)
38
+ ((date.day - 1) / 7) + 1
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ <section class="hrl-card">
2
+ <%= form_with model: appraisal, url: url, method: method, scope: :appraisal, local: true do |f| %>
3
+ <%= render "hr_lite/shared/form_errors", record: appraisal %>
4
+ <div class="hrl-field"><%= f.label :period_start %><%= f.date_field :period_start %></div>
5
+ <div class="hrl-field"><%= f.label :period_end %><%= f.date_field :period_end %></div>
6
+ <div class="hrl-field">
7
+ <%= f.label :rating, "Rating (1-5, optional)" %>
8
+ <%= f.select :rating, [ [ "No rating", "" ] ] + (1..5).map { |n| [ "#{n}/5", n ] } %>
9
+ </div>
10
+ <div class="hrl-field"><%= f.label :strengths %><%= f.text_area :strengths %></div>
11
+ <div class="hrl-field"><%= f.label :improvements, "Areas to grow" %><%= f.text_area :improvements %></div>
12
+ <div class="hrl-field">
13
+ <%= f.label :outcome %>
14
+ <%= f.select :outcome, HrLite::Appraisal::OUTCOMES.map { |o| [ o.humanize, o ] } %>
15
+ </div>
16
+ <div class="hrl-field">
17
+ <%= f.label :new_designation, "New designation (promotion only)" %>
18
+ <%= f.text_field :new_designation %>
19
+ </div>
20
+ <div class="hrl-field">
21
+ <%= f.label :effective_date, "Effective date (for increment/promotion)" %>
22
+ <%= f.date_field :effective_date %>
23
+ </div>
24
+ <div class="hrl-form-actions">
25
+ <%= f.submit "Save draft", class: "hrl-btn hrl-btn--primary" %>
26
+ <a class="hrl-btn" href="<%= hr_lite.admin_employee_path(@profile) %>">Cancel</a>
27
+ </div>
28
+ <% end %>
29
+ </section>
@@ -0,0 +1,6 @@
1
+ <% content_for(:page_title) { "Edit appraisal" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Edit appraisal — <%= hr_display_name(@profile.user) %></h1>
4
+ </div>
5
+ <%= render "form", appraisal: @appraisal,
6
+ url: hr_lite.admin_employee_appraisal_path(@profile, @appraisal), method: :patch %>
@@ -0,0 +1,6 @@
1
+ <% content_for(:page_title) { "New appraisal" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">New appraisal — <%= hr_display_name(@profile.user) %></h1>
4
+ </div>
5
+ <%= render "form", appraisal: @appraisal,
6
+ url: hr_lite.admin_employee_appraisals_path(@profile), method: :post %>