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,207 @@
1
+ module HrLite
2
+ class LeaveRequest < ApplicationRecord
3
+ STATUSES = %w[pending approved rejected cancelled].freeze
4
+
5
+ belongs_to :user, class_name: HrLite.config.user_class
6
+ belongs_to :leave_type
7
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
8
+
9
+ validates :start_date, :end_date, presence: true
10
+ validates :status, inclusion: { in: STATUSES }
11
+
12
+ with_options on: :create do
13
+ validate :end_after_start
14
+ validate :same_calendar_year
15
+ validate :half_day_single_day_only
16
+ validate :consumes_at_least_half_a_day
17
+ validate :no_overlap_with_own_requests
18
+ validate :no_punch_conflict
19
+ validate :sufficient_balance
20
+ end
21
+
22
+ before_validation :cache_days_count, on: :create
23
+ after_create :notify_requested
24
+
25
+ scope :pending, -> { where(status: "pending") }
26
+ scope :approved, -> { where(status: "approved") }
27
+ scope :active_on, ->(date) { approved.where(start_date: ..date).where(end_date: date..) }
28
+ scope :overlapping_range, ->(from, to) { where(start_date: ..to).where(end_date: from..) }
29
+ scope :recent_first, -> { order(start_date: :desc, id: :desc) }
30
+
31
+ STATUSES.each do |s|
32
+ define_method("#{s}?") { status == s }
33
+ end
34
+
35
+ def paid?
36
+ leave_type.paid
37
+ end
38
+
39
+ # --- transitions -------------------------------------------------------
40
+
41
+ # Returns false (leaving the request pending) when the balance no longer
42
+ # covers it — the re-check runs inside the row lock so two concurrent
43
+ # approvals cannot overdraw one balance.
44
+ def approve!(actor:, note: nil)
45
+ insufficient = false
46
+ transition!("approved", actor, note) do
47
+ if insufficient_balance_now?
48
+ insufficient = true
49
+ raise ActiveRecord::Rollback
50
+ end
51
+ end
52
+ return false if insufficient
53
+
54
+ notify_decision("Leave approved")
55
+ true
56
+ end
57
+
58
+ def reject!(actor:, note:)
59
+ transition!("rejected", actor, note)
60
+ notify_decision("Leave rejected")
61
+ true
62
+ end
63
+
64
+ # Owner may cancel while pending, or an approved future leave (quota
65
+ # returns automatically because `used` is computed). Admins may cancel
66
+ # any pending/approved future leave.
67
+ def cancellable_by?(actor)
68
+ return false unless pending? || (approved? && start_date > Date.current)
69
+
70
+ user_id == actor.id || HrLite.admin?(actor) || HrLite.leadership?(actor)
71
+ end
72
+
73
+ def cancel!(actor:)
74
+ was_approved = approved?
75
+ self.status = "cancelled"
76
+ self.decided_by_id = actor.id
77
+ self.decided_at = Time.current
78
+ save!
79
+
80
+ Notifications.publish(
81
+ "leave.cancelled",
82
+ title: "#{HrLite.display_name(user)} cancelled #{was_approved ? 'approved ' : ''}leave " \
83
+ "(#{date_range_label})",
84
+ path: "/admin/leave_requests",
85
+ bell_to: HrLite.admin_users
86
+ )
87
+ true
88
+ end
89
+
90
+ def date_range_label
91
+ if start_date == end_date
92
+ "#{start_date.strftime('%d %b')}#{half_day ? ' (half day)' : ''}"
93
+ else
94
+ "#{start_date.strftime('%d %b')} – #{end_date.strftime('%d %b')}"
95
+ end
96
+ end
97
+
98
+ def balance
99
+ LeaveBalance.for(user, leave_type, start_date.year)
100
+ end
101
+
102
+ private
103
+
104
+ def transition!(new_status, actor, note)
105
+ with_lock do
106
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
107
+
108
+ yield if block_given?
109
+ self.status = new_status
110
+ self.decided_by_id = actor.id
111
+ self.decided_at = Time.current
112
+ self.decision_note = note
113
+ save!
114
+ end
115
+ end
116
+
117
+ # This request is still pending here, so balance.used excludes it:
118
+ # approving is valid iff the request fits the remaining balance.
119
+ def insufficient_balance_now?
120
+ return false if leave_type.unlimited?
121
+
122
+ LeaveDayCounter.count(self) > balance.available
123
+ end
124
+
125
+ def notify_requested
126
+ Notifications.publish(
127
+ "leave.requested",
128
+ title: "#{HrLite.display_name(user)} applied for #{leave_type.name} (#{date_range_label})",
129
+ body: reason.presence,
130
+ path: "/admin/leave_requests/#{id}",
131
+ bell_to: HrLite.admin_users
132
+ )
133
+ end
134
+
135
+ def notify_decision(title)
136
+ Notifications.publish(
137
+ status == "approved" ? "leave.approved" : "leave.rejected",
138
+ title: "#{title} — #{leave_type.name} (#{date_range_label})",
139
+ body: decision_note.presence,
140
+ path: "/leave_requests/#{id}",
141
+ bell_to: [ user ],
142
+ email_to: [ user ]
143
+ )
144
+ end
145
+
146
+ def cache_days_count
147
+ self.days_count = LeaveDayCounter.count(self) if start_date && end_date
148
+ self.days_count ||= 0
149
+ end
150
+
151
+ def end_after_start
152
+ return unless start_date && end_date
153
+
154
+ errors.add(:end_date, "must be on or after the start date") if end_date < start_date
155
+ end
156
+
157
+ def same_calendar_year
158
+ return unless start_date && end_date
159
+
160
+ if start_date.year != end_date.year
161
+ errors.add(:base, "Split requests at the year boundary")
162
+ end
163
+ end
164
+
165
+ def half_day_single_day_only
166
+ errors.add(:half_day, "is only for single-day requests") if half_day && start_date != end_date
167
+ end
168
+
169
+ def consumes_at_least_half_a_day
170
+ return unless start_date && end_date
171
+ return if errors.any?
172
+
173
+ if LeaveDayCounter.count(self) <= 0
174
+ errors.add(:base, "Selected dates are all holidays or weekends")
175
+ end
176
+ end
177
+
178
+ def no_overlap_with_own_requests
179
+ return unless start_date && end_date
180
+
181
+ clash = self.class.where(user_id: user_id, status: %w[pending approved])
182
+ .where.not(id: id)
183
+ .overlapping_range(start_date, end_date)
184
+ errors.add(:base, "You already have leave overlapping these dates") if clash.exists?
185
+ end
186
+
187
+ # A full-day leave over a day that already has a check-in makes no
188
+ # sense; a half-day alongside a punch is legitimate (worked half).
189
+ def no_punch_conflict
190
+ return unless start_date && end_date
191
+ return if half_day
192
+
193
+ punched = AttendanceRecord.where(user_id: user_id, date: start_date..end_date)
194
+ .where.not(check_in_at: nil)
195
+ errors.add(:base, "You have marked attendance in this period") if punched.exists?
196
+ end
197
+
198
+ def sufficient_balance
199
+ return unless start_date && end_date && leave_type
200
+ return if leave_type.unlimited? || errors.any?
201
+
202
+ if LeaveDayCounter.count(self) > balance.available(as_of: start_date)
203
+ errors.add(:base, "Not enough #{leave_type.name} balance")
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,22 @@
1
+ module HrLite
2
+ class LeaveType < ApplicationRecord
3
+ include Audited
4
+
5
+ ACCRUALS = %w[yearly_upfront monthly].freeze
6
+
7
+ has_many :leave_requests, dependent: :restrict_with_error
8
+ has_many :leave_balances, dependent: :restrict_with_error
9
+
10
+ validates :name, :code, presence: true, uniqueness: true
11
+ validates :color, format: { with: /\A#\h{6}\z/ }
12
+ validates :accrual, inclusion: { in: ACCRUALS }
13
+ validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
14
+ validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
15
+
16
+ scope :active, -> { where(active: true).order(:position, :id) }
17
+
18
+ def unlimited?
19
+ annual_quota.nil?
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ class OfficeLocation < ApplicationRecord
3
+ include Audited
4
+
5
+ validates :name, presence: true
6
+ validates :lat, presence: true, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
7
+ validates :lng, presence: true, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }
8
+ validates :radius_m, presence: true, numericality: { only_integer: true, greater_than: 0 }
9
+
10
+ scope :active, -> { where(active: true) }
11
+
12
+ def self.covering?(lat, lng)
13
+ active.any? { |office| Geo.distance_m(office.lat, office.lng, lat, lng) <= office.radius_m }
14
+ end
15
+
16
+ # For flag notes: "1.2 km from Head Office". Nil when no offices exist.
17
+ def self.nearest(lat, lng)
18
+ active.min_by { |office| Geo.distance_m(office.lat, office.lng, lat, lng) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,112 @@
1
+ module HrLite
2
+ # Monthly run lifecycle: draft -> processing -> review -> finalized ->
3
+ # published (terminal). Compute is synchronous (pure-Ruby math over a
4
+ # small team; the seam for a background job is one perform_later away).
5
+ class PayrollRun < ApplicationRecord
6
+ STATUSES = %w[draft processing review finalized published].freeze
7
+
8
+ has_many :salary_slips, dependent: :destroy
9
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
10
+ belongs_to :finalized_by, class_name: HrLite.config.user_class, optional: true
11
+ belongs_to :published_by, class_name: HrLite.config.user_class, optional: true
12
+
13
+ validates :period_month, presence: true, uniqueness: true
14
+ validates :status, inclusion: { in: STATUSES }
15
+ validate :period_is_first_of_month
16
+ before_destroy :draft_only_destroy
17
+
18
+ scope :recent_first, -> { order(period_month: :desc) }
19
+
20
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
21
+
22
+ def editable?
23
+ draft? || processing? || review?
24
+ end
25
+
26
+ def label
27
+ period_month.strftime("%B %Y")
28
+ end
29
+
30
+ def compute!(actor:)
31
+ raise_unless %w[draft review]
32
+
33
+ update!(status: "processing")
34
+ PayrollRunProcessor.call(self)
35
+ update!(status: "review", processed_at: Time.current)
36
+ true
37
+ rescue => e
38
+ update_columns(status: "draft") # rubocop:disable Rails/SkipsModelValidations
39
+ raise e
40
+ end
41
+
42
+ def finalize!(actor:)
43
+ raise_unless %w[review]
44
+ raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
45
+
46
+ update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
47
+ Notifications.publish(
48
+ "payroll.finalized",
49
+ title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
50
+ path: "/admin/payroll_runs/#{id}"
51
+ )
52
+ true
53
+ end
54
+
55
+ def unlock!(actor:)
56
+ raise_unless %w[finalized]
57
+ update!(status: "review")
58
+ true
59
+ end
60
+
61
+ def publish!(actor:)
62
+ raise_unless %w[finalized]
63
+ update!(status: "published", published_at: Time.current, published_by_id: actor.id)
64
+
65
+ slips = salary_slips.includes(:user).to_a
66
+ Notifications.publish(
67
+ "payroll.published",
68
+ title: "Your salary slip for #{label} is ready",
69
+ body: "Open Earthly HR to view or download it.",
70
+ path: "/salary_slips",
71
+ bell_to: slips.map(&:user),
72
+ email_to: slips.map(&:user)
73
+ )
74
+ true
75
+ end
76
+
77
+ # Ruby-side aggregates (amounts are encrypted — no SQL sums).
78
+ def total_gross = sum_slips(:gross_earnings)
79
+ def total_deductions = sum_slips(:total_deductions)
80
+ def total_net = sum_slips(:net_pay)
81
+
82
+ def total_employer_cost
83
+ salary_slips.sum(BigDecimal(0)) do |slip|
84
+ slip.employer_costs_hash.values.sum(BigDecimal(0)) { |v| Money.d(v) }
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def sum_slips(attribute)
91
+ salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
92
+ end
93
+
94
+ def raise_unless(allowed)
95
+ raise ActiveRecord::RecordInvalid.new(self), "invalid transition" unless allowed.include?(status)
96
+ end
97
+
98
+ def period_is_first_of_month
99
+ return unless period_month
100
+ return if period_month.day == 1
101
+
102
+ errors.add(:period_month, "must be the 1st of a month")
103
+ end
104
+
105
+ def draft_only_destroy
106
+ return if draft?
107
+
108
+ errors.add(:base, "Only draft runs can be deleted")
109
+ throw :abort
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,78 @@
1
+ module HrLite
2
+ # One employee-month. All MONEY is snapshotted (encrypted JSON line items +
3
+ # encrypted totals); identity numbers (PAN/UAN/bank) render live from the
4
+ # profile — a typo fix must flow to old slips, and duplicating encrypted
5
+ # PII per month multiplies liability. Visibility and immutability delegate
6
+ # to the run's lifecycle.
7
+ class SalarySlip < ApplicationRecord
8
+ include EncryptedMoney
9
+
10
+ belongs_to :payroll_run
11
+ belongs_to :user, class_name: HrLite.config.user_class
12
+
13
+ encrypts :earnings, :deductions, :employer_costs, :tax_details
14
+ encrypted_money :tds_override, :gross_earnings, :total_deductions, :net_pay
15
+
16
+ validates :period_month, presence: true, uniqueness: { scope: :user_id }
17
+ validates :user_id, uniqueness: { scope: :payroll_run_id }
18
+ before_save :ensure_run_editable
19
+
20
+ scope :published, -> { joins(:payroll_run).where(hr_lite_payroll_runs: { status: "published" }) }
21
+ scope :recent_first, -> { order(period_month: :desc) }
22
+
23
+ def published?
24
+ payroll_run.published?
25
+ end
26
+
27
+ def earnings_rows = parse_json(earnings)
28
+ def deductions_rows = parse_json(deductions)
29
+ def employer_costs_hash = parse_json(employer_costs, fallback: {})
30
+ def tax_details_hash = parse_json(tax_details, fallback: {})
31
+
32
+ def effective_lop_days
33
+ lop_override || lop_days
34
+ end
35
+
36
+ # Indian FY (Apr 1) to-date sums of PUBLISHED slips before this period —
37
+ # feeds the TDS projector and the slip's YTD table.
38
+ def self.fy_to_date(user, period_month)
39
+ fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
40
+ : Date.new(period_month.year - 1, 4, 1)
41
+ slips = published.where(user_id: user.id)
42
+ .where(period_month: fy_start...period_month)
43
+ {
44
+ gross: slips.sum(BigDecimal(0)) { |s| s.gross_earnings || BigDecimal(0) },
45
+ tds: slips.sum(BigDecimal(0)) { |s| Money.d(s.deduction_amount("tds")) },
46
+ months: slips.count
47
+ }
48
+ end
49
+
50
+ def deduction_amount(code)
51
+ row = deductions_rows.find { |r| r["code"] == code }
52
+ row ? Money.d(row["amount"]) : BigDecimal(0)
53
+ end
54
+
55
+ def pdf_cache_key
56
+ [ "hr_lite_slip", id, updated_at.to_i, payroll_run.status,
57
+ user_profile&.updated_at.to_i ].join("/")
58
+ end
59
+
60
+ def user_profile
61
+ @user_profile ||= EmployeeProfile.find_by(user_id: user_id)
62
+ end
63
+
64
+ private
65
+
66
+ def parse_json(raw, fallback: [])
67
+ raw.blank? ? fallback : JSON.parse(raw)
68
+ end
69
+
70
+ def ensure_run_editable
71
+ return if payroll_run.nil? || payroll_run.editable?
72
+ # The lifecycle transitions themselves touch only the run row, never
73
+ # slip attributes — any slip write outside draft/processing/review is
74
+ # a bug or tampering.
75
+ raise ActiveRecord::ReadOnlyRecord, "slips are immutable once the run is finalized"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,49 @@
1
+ module HrLite
2
+ # Versioned by effective_from (always the 1st — mid-month revisions and
3
+ # their blended-rate complexity are deliberately unsupported). Structures
4
+ # are never destroyed; a new revision supersedes. Slips snapshot every
5
+ # computed number, so editing a structure cannot corrupt history.
6
+ class SalaryStructure < ApplicationRecord
7
+ include EncryptedMoney
8
+ include Audited
9
+
10
+ belongs_to :user, class_name: HrLite.config.user_class
11
+ belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
12
+
13
+ encrypted_money :basic, :hra, :special_allowance, :other_earnings
14
+
15
+ validates :effective_from, presence: true, uniqueness: { scope: :user_id }
16
+ validates :basic, presence: true
17
+ validate :basic_positive
18
+ validate :effective_from_is_first_of_month
19
+ validates :pt_state, presence: true
20
+
21
+ def self.effective_for(user, period_month)
22
+ where(user_id: user.id)
23
+ .where(effective_from: ..period_month.beginning_of_month)
24
+ .order(effective_from: :desc)
25
+ .first
26
+ end
27
+
28
+ def monthly_gross
29
+ [ basic, hra, special_allowance, other_earnings ].compact.sum(BigDecimal(0))
30
+ end
31
+
32
+ def annual_gross
33
+ monthly_gross * 12
34
+ end
35
+
36
+ private
37
+
38
+ def basic_positive
39
+ errors.add(:basic, "must be greater than zero") if basic && basic <= 0
40
+ end
41
+
42
+ def effective_from_is_first_of_month
43
+ return unless effective_from
44
+ return if effective_from.day == 1
45
+
46
+ errors.add(:effective_from, "must be the 1st of a month")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # Engine-owned singleton settings row.
3
+ class Setting < ApplicationRecord
4
+ include Audited
5
+
6
+ WEEKEND_POLICIES = %w[sun_only sat_sun second_fourth_sat_sun].freeze
7
+
8
+ validates :weekend_policy, inclusion: { in: WEEKEND_POLICIES }
9
+
10
+ def self.instance
11
+ first_or_create!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,103 @@
1
+ module HrLite
2
+ # The single write path for employee punches. Race-safe (DB unique index +
3
+ # row lock + in-lock re-check), never blocked by geolocation: a punch
4
+ # without GPS or outside every office radius is recorded AND flagged —
5
+ # denying GPS must not stop anyone from working.
6
+ class AttendancePuncher
7
+ Result = Struct.new(:record, :error, keyword_init: true) do
8
+ def ok? = error.nil?
9
+ end
10
+
11
+ def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok")
12
+ new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s).call
13
+ end
14
+
15
+ def initialize(user, kind, lat, lng, accuracy_m, geo_status)
16
+ @user = user
17
+ @kind = kind
18
+ @lat = lat.presence
19
+ @lng = lng.presence
20
+ @accuracy_m = accuracy_m.presence
21
+ @geo_status = geo_status
22
+ end
23
+
24
+ def call
25
+ record = resolve_record
26
+ return Result.new(error: "No open check-in to close.") if record.nil?
27
+
28
+ record.with_lock do
29
+ case @kind
30
+ when :check_in
31
+ return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
32
+
33
+ stamp(record, :check_in)
34
+ when :check_out
35
+ return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
36
+
37
+ stamp(record, :check_out) # repeat check-out overwrites: last out wins
38
+ else
39
+ return Result.new(error: "Unknown punch #{@kind}.")
40
+ end
41
+
42
+ apply_geo_flags(record)
43
+ record.save!
44
+ end
45
+ notify_if_flagged(record)
46
+ Result.new(record: record)
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_record
52
+ if @kind == :check_out
53
+ today = AttendanceRecord.find_by(user_id: @user.id, date: Date.current)
54
+ return today if today&.check_in_at.present?
55
+
56
+ # Post-midnight close of yesterday's open punch (office night, IST).
57
+ return AttendanceRecord.for_date(Date.current - 1)
58
+ .missing_checkout.find_by(user_id: @user.id)
59
+ end
60
+
61
+ AttendanceRecord.find_or_create_by!(user_id: @user.id, date: Date.current)
62
+ rescue ActiveRecord::RecordNotUnique
63
+ retry
64
+ end
65
+
66
+ def stamp(record, side)
67
+ record.public_send("#{side}_at=", Time.current)
68
+ record.public_send("#{side}_lat=", @lat)
69
+ record.public_send("#{side}_lng=", @lng)
70
+ record.public_send("#{side}_accuracy_m=", @accuracy_m)
71
+ end
72
+
73
+ def already_in_message(record)
74
+ "Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
75
+ end
76
+
77
+ def apply_geo_flags(record)
78
+ label = @kind == :check_in ? "Check-in" : "Check-out"
79
+
80
+ if @lat.nil? || @lng.nil?
81
+ reason = @geo_status == "ok" ? "unavailable" : @geo_status
82
+ record.add_flag!("#{label} without GPS (#{reason})")
83
+ elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
84
+ office = OfficeLocation.nearest(@lat, @lng)
85
+ distance_km = (Geo.distance_m(office.lat, office.lng, @lat, @lng) / 1000.0).round(1)
86
+ accuracy = @accuracy_m ? " (±#{@accuracy_m} m)" : ""
87
+ record.add_flag!("#{label} #{distance_km} km from #{office.name}#{accuracy}")
88
+ end
89
+ end
90
+
91
+ def notify_if_flagged(record)
92
+ return unless record.saved_changes.key?("flag_note") && record.flagged?
93
+
94
+ Notifications.publish(
95
+ "attendance.flagged",
96
+ title: "Flagged punch — #{HrLite.display_name(@user)}",
97
+ body: record.flag_note,
98
+ path: "/admin/attendances?date=#{record.date}",
99
+ bell_to: HrLite.admin_users
100
+ )
101
+ end
102
+ end
103
+ end