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,60 @@
1
+ module HrLite
2
+ # All host integration points. Every attribute has a working default so the
3
+ # engine boots in any Rails app; a real host overrides what it needs in an
4
+ # initializer. See README for the full annotated example.
5
+ class Configuration
6
+ attr_accessor :user_class, :parent_controller, :current_user_method,
7
+ :authenticate_method, :admin_check, :display_name_method,
8
+ :employees_scope, :mentionable_users, :notify, :render_pdf, :company,
9
+ :time_zone, :currency_symbol, :on_designation_change,
10
+ :leadership_emails, :leadership_check, :extra_stylesheets,
11
+ :mailer_from, :public_url_base, :notification_matrix, :back_link
12
+
13
+ # 0.1.0 pre-release name for public_url_base; kept as an alias so early
14
+ # adopters' initializers don't break.
15
+ alias_method :mail_link_base, :public_url_base
16
+ alias_method :mail_link_base=, :public_url_base=
17
+
18
+ def initialize
19
+ @user_class = "User"
20
+ @parent_controller = "ActionController::Base"
21
+ @current_user_method = :current_user
22
+ @authenticate_method = :authenticate_user!
23
+ @admin_check = ->(user) { user.respond_to?(:admin?) && user.admin? }
24
+ @display_name_method = :display_name
25
+ @employees_scope = -> { HrLite.user_klass.all }
26
+ @mentionable_users = ->(query) { HrLite.default_mentionable_users(query) }
27
+ @notify = ->(user:, kind:, title:, body:, path:) { }
28
+ @render_pdf = nil
29
+ @company = -> { { name: "Company", address: nil, logo_path: nil } }
30
+ @time_zone = "Asia/Kolkata"
31
+ @currency_symbol = "₹"
32
+ @on_designation_change = ->(user, designation) { }
33
+ @leadership_emails = []
34
+ @leadership_check = ->(user) do
35
+ emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
36
+ emails.include?(user.email.to_s.downcase)
37
+ end
38
+ @extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
39
+ @mailer_from = "hr@example.com"
40
+ @public_url_base = nil # e.g. "https://hr.example.com" — enables email links + HrLite.public_url
41
+ @notification_matrix = nil # resolved lazily to Notifications::DEFAULT_MATRIX
42
+ @back_link = nil # optional {label:, url:} for the shell nav
43
+ end
44
+ end
45
+
46
+ class << self
47
+ # Default @mention source: name/email prefix match on the host user table.
48
+ # Uses LOWER(...) LIKE so it works on sqlite and postgres alike; hosts
49
+ # with pg_trgm or scopes of their own override config.mentionable_users.
50
+ def default_mentionable_users(query)
51
+ q = "%#{ActiveRecord::Base.sanitize_sql_like(query.to_s.downcase)}%"
52
+ klass = user_klass
53
+ columns = %w[name email].select { |c| klass.column_names.include?(c) }
54
+ return klass.none if columns.empty?
55
+
56
+ where_sql = columns.map { |c| "LOWER(#{klass.table_name}.#{c}) LIKE :q" }.join(" OR ")
57
+ klass.where(where_sql, q: q).order(:id).limit(8)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,7 @@
1
+ module HrLite
2
+ # Request-scoped actor for audit logging. Set by ApplicationController,
3
+ # readable from any model callback without threading it through.
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :actor
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ module HrLite
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace HrLite
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec
7
+ end
8
+
9
+ # Make the engine's plain CSS/JS visible to whichever asset pipeline the
10
+ # host runs (Propshaft or Sprockets both read config.assets.paths).
11
+ initializer "hr_lite.assets" do |app|
12
+ if app.config.respond_to?(:assets)
13
+ app.config.assets.paths << root.join("app/assets/stylesheets")
14
+ app.config.assets.paths << root.join("app/assets/javascripts")
15
+ end
16
+ end
17
+
18
+ # Serve the engine's migrations straight from the gem: `bin/rails
19
+ # db:migrate` in the host just works, and gem upgrades ship their
20
+ # migrations automatically — nothing to copy, nothing to drift.
21
+ #
22
+ # Skipped when the host already copied them via
23
+ # `hr_lite:install:migrations` (files named *.hr_lite.rb) — otherwise the
24
+ # same tables would be created twice under different version stamps.
25
+ initializer "hr_lite.migrations" do |app|
26
+ next if app.root.to_s == root.to_s
27
+ next unless HrLite::Engine.append_migrations?(app)
28
+
29
+ config.paths["db/migrate"].expanded.each do |path|
30
+ app.config.paths["db/migrate"] << path
31
+ end
32
+ end
33
+
34
+ def self.append_migrations?(app)
35
+ host_migrate_dirs = app.config.paths["db/migrate"].expanded
36
+ host_migrate_dirs.none? { |dir| Dir.glob(File.join(dir, "*.hr_lite.rb")).any? }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ module HrLite
2
+ # Pure-Ruby haversine — no geocoding gem needed to answer "how far is
3
+ # this punch from the office?" at office-radius precision.
4
+ module Geo
5
+ EARTH_RADIUS_M = 6_371_000.0
6
+
7
+ def self.distance_m(lat1, lng1, lat2, lng2)
8
+ rlat1 = deg2rad(lat1)
9
+ rlat2 = deg2rad(lat2)
10
+ dlat = deg2rad(lat2.to_f - lat1.to_f)
11
+ dlng = deg2rad(lng2.to_f - lng1.to_f)
12
+
13
+ a = Math.sin(dlat / 2)**2 + Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(dlng / 2)**2
14
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
15
+ EARTH_RADIUS_M * c
16
+ end
17
+
18
+ def self.deg2rad(deg)
19
+ deg.to_f * Math::PI / 180
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ # Kudos messages carry mentions as visible plain-text markers:
3
+ # "Great save @[Asha Rao](42) during the outage"
4
+ # The picker inserts them; this parser extracts the ids server-side.
5
+ module MentionParser
6
+ MARKER = /@\[([^\]\n]{1,80})\]\((\d+)\)/
7
+ LIMIT = 10 # notification-spam cap
8
+
9
+ def self.user_ids(text)
10
+ text.to_s.scan(MARKER).map { |(_name, id)| id.to_i }.uniq.first(LIMIT)
11
+ end
12
+
13
+ # Bell/email bodies show "@Asha Rao", never the raw marker.
14
+ def self.strip_markers(text)
15
+ text.to_s.gsub(MARKER) { "@#{Regexp.last_match(1)}" }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ module HrLite
2
+ # Central rounding discipline. Each statutory line rounds by its own rule
3
+ # exactly once; nets are plain subtraction of already-rounded lines.
4
+ module Money
5
+ module_function
6
+
7
+ def d(value)
8
+ return BigDecimal(0) if value.nil?
9
+
10
+ value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
11
+ end
12
+
13
+ # Nearest rupee, half-up (PF, TDS monthly).
14
+ def round_rupee(value)
15
+ d(value).round(0, BigDecimal::ROUND_HALF_UP)
16
+ end
17
+
18
+ # Next rupee (ESIC rounds contributions UP).
19
+ def ceil_rupee(value)
20
+ d(value).ceil(0)
21
+ end
22
+
23
+ # Paise precision (earnings proration).
24
+ def round2(value)
25
+ d(value).round(2)
26
+ end
27
+
28
+ # Section 288B: annual tax to the nearest ten rupees.
29
+ def round_to_10(value)
30
+ (d(value) / 10).round(0, BigDecimal::ROUND_HALF_UP) * 10
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,99 @@
1
+ module HrLite
2
+ # Central event bus. Domain code publishes an event; the per-event channel
3
+ # row (host-overridable via config.notification_matrix) decides which
4
+ # channels fire. Channel semantics:
5
+ #
6
+ # bell: in-app notification (config.notify) to `bell_to` users
7
+ # email: EventMailer to each `email_to` user
8
+ # leadership_email: ONE email to every configured leadership address
9
+ # leadership_bell: in-app notification to each leadership user record
10
+ #
11
+ # Recipients are computed by the publishing site (it knows the domain);
12
+ # the matrix is purely the on/off routing table.
13
+ module Notifications
14
+ DEFAULT_MATRIX = {
15
+ "leave.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: true },
16
+ "leave.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
17
+ "leave.rejected" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
18
+ "leave.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
19
+ "attendance.flagged" => { bell: true, email: false, leadership_email: false, leadership_bell: false },
20
+ "attendance.regularized" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
21
+ "payroll.finalized" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
22
+ "payroll.published" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
23
+ "kudos.mentioned" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
24
+ "appraisal.shared" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
25
+ "promotion.recorded" => { bell: true, email: true, leadership_email: true, leadership_bell: true },
26
+ "policy.changed" => { bell: false, email: false, leadership_email: true, leadership_bell: true },
27
+ "digest.daily" => { bell: false, email: false, leadership_email: true, leadership_bell: false }
28
+ }.freeze
29
+
30
+ class << self
31
+ def matrix
32
+ HrLite.config.notification_matrix || DEFAULT_MATRIX
33
+ end
34
+
35
+ def publish(event, title:, body: nil, path: nil, bell_to: [], email_to: [], lines: [], diff: nil)
36
+ row = matrix[event.to_s]
37
+ unless row
38
+ Rails.logger.warn("[hr_lite] unknown notification event #{event}")
39
+ return
40
+ end
41
+
42
+ deliver_bells(event, row, bell_to, title, body, path)
43
+ deliver_emails(row, email_to, title, body, path, lines)
44
+ deliver_leadership_email(event, row, title, body, path, lines, diff)
45
+ deliver_leadership_bells(event, row, bell_to, title, body, path)
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ def deliver_bells(event, row, bell_to, title, body, path)
52
+ return unless row[:bell]
53
+
54
+ Array(bell_to).compact.uniq.each do |user|
55
+ HrLite.notify(user: user, kind: event.to_s, title: title, body: body, path: path)
56
+ end
57
+ end
58
+
59
+ def deliver_emails(row, email_to, title, body, path, lines)
60
+ return unless row[:email]
61
+
62
+ Array(email_to).compact.uniq.each do |user|
63
+ next if user.email.blank?
64
+
65
+ EventMailer.event(to: user.email, subject: title, heading: title,
66
+ body: body, lines: lines, path: path).deliver_later
67
+ end
68
+ rescue => e
69
+ Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
70
+ end
71
+
72
+ def deliver_leadership_email(event, row, title, body, path, lines, diff)
73
+ return unless row[:leadership_email]
74
+
75
+ recipients = HrLite.config.leadership_emails.map { |e| e.to_s.strip }.reject(&:empty?)
76
+ return if recipients.empty?
77
+
78
+ EventMailer.leadership(to: recipients, subject: "[HR] #{title}", heading: title,
79
+ body: body, lines: lines, diff: diff, path: path, event: event.to_s)
80
+ .deliver_later
81
+ rescue => e
82
+ Rails.logger.error("[hr_lite] leadership email failed: #{e.class}: #{e.message}")
83
+ end
84
+
85
+ def deliver_leadership_bells(event, row, bell_to, title, body, path)
86
+ return unless row[:leadership_bell]
87
+
88
+ already = Array(bell_to).compact
89
+ HrLite.leadership_users.each do |leader|
90
+ next if already.any? { |u| u.id == leader.id }
91
+
92
+ HrLite.notify(user: leader, kind: event.to_s, title: title, body: body, path: path)
93
+ end
94
+ rescue => e
95
+ Rails.logger.error("[hr_lite] leadership bell failed: #{e.class}: #{e.message}")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,42 @@
1
+ module HrLite
2
+ # Idempotent reference data. Hosts run it on every deploy
3
+ # (`rake hr_lite:seed`); re-runs never overwrite operator edits.
4
+ module Seeds
5
+ DEFAULT_LEAVE_TYPES = [
6
+ { code: "CL", name: "Casual leave", color: "#0ea5e9", paid: true, annual_quota: 12, accrual: "monthly", carry_forward_cap: 0, position: 1 },
7
+ { code: "SL", name: "Sick leave", color: "#f59e0b", paid: true, annual_quota: 6, accrual: "yearly_upfront", carry_forward_cap: 0, position: 2 },
8
+ { code: "EL", name: "Earned leave", color: "#10b981", paid: true, annual_quota: 15, accrual: "monthly", carry_forward_cap: 30, position: 3 },
9
+ { code: "LWP", name: "Leave without pay", color: "#6b7280", paid: false, annual_quota: nil, accrual: "yearly_upfront", carry_forward_cap: 0, position: 4 },
10
+ { code: "CO", name: "Comp off", color: "#8b5cf6", paid: true, annual_quota: 0, accrual: "yearly_upfront", carry_forward_cap: 0, position: 5 }
11
+ ].freeze
12
+
13
+ def self.run!
14
+ seed_leave_types! + seed_holidays!
15
+ end
16
+
17
+ def self.seed_leave_types!
18
+ DEFAULT_LEAVE_TYPES.filter_map do |attrs|
19
+ next if LeaveType.exists?(code: attrs[:code])
20
+
21
+ LeaveType.create!(attrs)
22
+ "leave_type #{attrs[:code]}"
23
+ end
24
+ end
25
+
26
+ # Only the three fixed-date national holidays — festival dates shift
27
+ # every year and belong to the admin bulk-paste flow.
28
+ def self.seed_holidays!
29
+ year = Date.current.year
30
+ [
31
+ [ Date.new(year, 1, 26), "Republic Day" ],
32
+ [ Date.new(year, 8, 15), "Independence Day" ],
33
+ [ Date.new(year, 10, 2), "Gandhi Jayanti" ]
34
+ ].filter_map do |date, name|
35
+ next if Holiday.exists?(date: date)
36
+
37
+ Holiday.create!(date: date, name: name)
38
+ "holiday #{name} #{year}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ module HrLite
2
+ # Config-driven statutory rates, keyed by effective date — a budget change
3
+ # is a one-hash edit that gets code review and a spec diff, never an
4
+ # inline-constant hunt.
5
+ #
6
+ # VERIFY WITH A CA before the first run of any new financial year; the
7
+ # FY 2026-27 card ships with FY 2025-26 figures pending confirmation of
8
+ # the Feb 2026 Finance Act.
9
+ module StatutoryRateCard
10
+ def self.r(value) = BigDecimal(value.to_s)
11
+ private_class_method :r
12
+
13
+ CARDS = {
14
+ Date.new(2025, 4, 1) => {
15
+ pf: {
16
+ employee_rate: r("0.12"), employer_rate: r("0.12"), eps_rate: r("0.0833"),
17
+ wage_ceiling: r("15000"), eps_wage_ceiling: r("15000"),
18
+ edli_rate: r("0.005"), edli_ceiling: r("15000"), admin_rate: r("0.005")
19
+ },
20
+ esi: { employee_rate: r("0.0075"), employer_rate: r("0.0325"), gross_ceiling: r("21000") },
21
+ pt: {
22
+ # Neither UP nor Uttarakhand levies professional tax today; both
23
+ # ship empty (PT = 0). Karnataka included as a worked template.
24
+ "none" => [],
25
+ "uttar_pradesh" => [],
26
+ "uttarakhand" => [],
27
+ "karnataka" => [ { above: r("24999"), monthly: r("200") } ]
28
+ },
29
+ income_tax: {
30
+ "new" => {
31
+ standard_deduction: r("75000"), rebate_cap: r("1200000"), cess_rate: r("0.04"),
32
+ slabs: [
33
+ [ r("0"), r("400000"), r("0") ],
34
+ [ r("400000"), r("800000"), r("0.05") ],
35
+ [ r("800000"), r("1200000"), r("0.10") ],
36
+ [ r("1200000"), r("1600000"), r("0.15") ],
37
+ [ r("1600000"), r("2000000"), r("0.20") ],
38
+ [ r("2000000"), r("2400000"), r("0.25") ],
39
+ [ r("2400000"), nil, r("0.30") ]
40
+ ]
41
+ },
42
+ "old" => {
43
+ standard_deduction: r("50000"), rebate_cap: r("500000"), cess_rate: r("0.04"),
44
+ slabs: [
45
+ [ r("0"), r("250000"), r("0") ],
46
+ [ r("250000"), r("500000"), r("0.05") ],
47
+ [ r("500000"), r("1000000"), r("0.20") ],
48
+ [ r("1000000"), nil, r("0.30") ]
49
+ ]
50
+ }
51
+ }
52
+ }
53
+ }.freeze
54
+
55
+ def self.for(period_month)
56
+ effective = CARDS.keys.sort.reverse.find { |date| date <= period_month }
57
+ effective ? CARDS[effective] : CARDS[CARDS.keys.min]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module HrLite
2
+ VERSION = "0.1.0"
3
+ end
data/lib/hr_lite.rb ADDED
@@ -0,0 +1,104 @@
1
+ require "hr_lite/version"
2
+ require "hr_lite/engine"
3
+ require "hr_lite/configuration"
4
+ require "hr_lite/current"
5
+ require "hr_lite/mention_parser"
6
+ require "hr_lite/notifications"
7
+ require "hr_lite/seeds"
8
+ require "hr_lite/geo"
9
+ require "hr_lite/money"
10
+ require "hr_lite/statutory_rate_card"
11
+
12
+ module HrLite
13
+ class << self
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield config
20
+ end
21
+
22
+ # Test-only escape hatch: swap the whole configuration object.
23
+ def config=(configuration)
24
+ @config = configuration
25
+ end
26
+
27
+ def user_klass
28
+ config.user_class.constantize
29
+ end
30
+
31
+ def admin?(user)
32
+ user.present? && !!config.admin_check.call(user)
33
+ end
34
+
35
+ def leadership?(user)
36
+ user.present? && !!config.leadership_check.call(user)
37
+ end
38
+
39
+ # Leadership members resolvable to actual user records (for bell
40
+ # notifications). Emails configured but absent from the user table are
41
+ # still reachable by email — see Notifications.
42
+ def leadership_users
43
+ emails = config.leadership_emails.map { |e| e.to_s.downcase.strip }.reject(&:empty?)
44
+ return user_klass.none if emails.empty?
45
+
46
+ user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
47
+ end
48
+
49
+ def admin_users
50
+ user_klass.all.select { |u| admin?(u) }
51
+ end
52
+
53
+ # Everyone HR tracks (host-overridable to exclude bots/test accounts),
54
+ # sorted by display name for team screens.
55
+ def employees
56
+ config.employees_scope.call.sort_by { |u| display_name(u).downcase }
57
+ end
58
+
59
+ def display_name(user)
60
+ return "" if user.nil?
61
+
62
+ [ config.display_name_method, :display_name, :name, :email ].each do |m|
63
+ next unless m && user.respond_to?(m)
64
+ value = user.public_send(m).presence
65
+ return value if value
66
+ end
67
+ "User ##{user.id}"
68
+ end
69
+
70
+ # Absolute public URL for an engine-relative path, from
71
+ # config.public_url_base. Nil when no base is configured — callers (and
72
+ # emails) then simply carry no link. Hosts use this to build bell
73
+ # deep-links and profile links without re-deriving the HR host.
74
+ def public_url(path = "/")
75
+ base = config.public_url_base.to_s.chomp("/")
76
+ return nil if base.empty?
77
+
78
+ "#{base}#{path}"
79
+ end
80
+
81
+ # True when the given absolute URL points at the configured public HR
82
+ # host — the allowlist check for hosts that follow stored notification
83
+ # links (an open-redirect guard stays intact on their side).
84
+ def public_url?(candidate)
85
+ base = public_url
86
+ return false if base.nil?
87
+
88
+ candidate_uri = URI.parse(candidate.to_s)
89
+ base_uri = URI.parse(base)
90
+ %w[http https].include?(candidate_uri.scheme) && candidate_uri.host == base_uri.host
91
+ rescue URI::InvalidURIError
92
+ false
93
+ end
94
+
95
+ # Host bell hook. Never raises — a notification must not break the
96
+ # domain action that triggered it.
97
+ def notify(user:, kind:, title:, body: nil, path: nil)
98
+ config.notify.call(user: user, kind: kind, title: title, body: body, path: path)
99
+ rescue => e
100
+ Rails.logger.error("[hr_lite] notify failed: #{e.class}: #{e.message}")
101
+ nil
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,8 @@
1
+ namespace :hr_lite do
2
+ desc "Idempotently seed HR reference data (leave types, national holidays)"
3
+ task seed: :environment do
4
+ require "hr_lite/seeds"
5
+ created = HrLite::Seeds.run!
6
+ puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
7
+ end
8
+ end