Class: HrLite::LeaveBalance

Inherits:
ApplicationRecord show all
Defined in:
app/models/hr_lite/leave_balance.rb

Overview

Hybrid balance: only carry-in and manual adjustments are stored; entitlement accrues as a pure function of the policy and used is recomputed live from approved requests — so a holiday added after an approval self-heals both the quota and payroll.

year is a LeaveYear key (calendar year by default; with config.leave_year_start_month = 7, key 2026 = Jul 2026 – Jun 2027). Entitlement prorates from the joining date, Keka-style: joined on or before the 15th → that month counts; after the 15th → from next month.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.adjust!(user, leave_type, year, delta:, note:) ⇒ Object

The single write path for the stored adjustment. Both callers — the comp-off credit and the admin correction — increment the same column, and read-modify-write without the lock lost one of them.



40
41
42
43
44
45
46
47
48
# File 'app/models/hr_lite/leave_balance.rb', line 40

def self.adjust!(user, leave_type, year, delta:, note:)
  transaction do
    balance = lock_for(user, leave_type, year)
    balance.adjustment += delta
    balance.adjustment_note = [ balance.adjustment_note.presence, note ].compact.join("; ")
    balance.save!
    balance
  end
end

.for(user, leave_type, year) ⇒ Object



18
19
20
# File 'app/models/hr_lite/leave_balance.rb', line 18

def self.for(user, leave_type, year)
  find_or_initialize_by(user_id: user.id, leave_type: leave_type, year: year)
end

.lock_for(user, leave_type, year) ⇒ Object

Persisted and row-locked so concurrent writers serialize here. The create sits in its own savepoint: on PostgreSQL a failed INSERT aborts the enclosing transaction, so a plain rescue could never run.



25
26
27
28
29
30
31
32
33
34
35
# File 'app/models/hr_lite/leave_balance.rb', line 25

def self.lock_for(user, leave_type, year)
  attributes = { user_id: user.id, leave_type_id: leave_type.id, year: year }
  balance = find_by(attributes)
  balance ||= begin
    transaction(requires_new: true) { create!(attributes) }
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
    find_by!(attributes)
  end
  balance.lock!
  balance
end

Instance Method Details

#available(as_of: Date.current) ⇒ Object



69
70
71
# File 'app/models/hr_lite/leave_balance.rb', line 69

def available(as_of: Date.current)
  entitled(as_of: as_of) - used
end

#entitled(as_of: Date.current) ⇒ Object



50
51
52
53
54
# File 'app/models/hr_lite/leave_balance.rb', line 50

def entitled(as_of: Date.current)
  return Float::INFINITY if leave_type.unlimited?

  (accrued_base(as_of) + carried_forward + adjustment).round(1)
end

#usedObject



56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/models/hr_lite/leave_balance.rb', line 56

def used
  range = LeaveYear.range(year)
  requests = LeaveRequest.approved
                         .where(user_id: user_id, leave_type_id: leave_type_id)
                         .where(start_date: range)
  # ONE calendar for the whole leave year, reused across every request.
  # Building one per request meant a Holiday query and a Setting query
  # each — and the admin balances grid reads this cell for every employee
  # times every leave type.
  calendar = WorkingCalendar.new(range)
  requests.sum { |request| LeaveDayCounter.count(request, calendar: calendar) }
end