Class: HrLite::PayrollRun

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

Overview

Monthly run lifecycle: draft -> processing -> review -> finalized -> published (terminal). Compute is synchronous (pure-Ruby math over a small team; the seam for a background job is one perform_later away).

Constant Summary collapse

STATUSES =
%w[draft processing review finalized published].freeze

Instance Method Summary collapse

Instance Method Details

#compute!(actor:) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
# File 'app/models/hr_lite/payroll_run.rb', line 30

def compute!(actor:)
  raise_unless %w[draft review]

  update!(status: "processing")
  PayrollRunProcessor.call(self)
  update!(status: "review", processed_at: Time.current)
  true
rescue => e
  update_columns(status: "draft") # rubocop:disable Rails/SkipsModelValidations
  raise e
end

#editable?Boolean

Returns:

  • (Boolean)


22
23
24
# File 'app/models/hr_lite/payroll_run.rb', line 22

def editable?
  draft? || processing? || review?
end

#finalize!(actor:) ⇒ Object

Raises:

  • (ActiveRecord::RecordInvalid.new(self))


42
43
44
45
46
47
48
49
50
51
52
53
# File 'app/models/hr_lite/payroll_run.rb', line 42

def finalize!(actor:)
  raise_unless %w[review]
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?

  update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
  Notifications.publish(
    "payroll.finalized",
    title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
    path: "/admin/payroll_runs/#{id}"
  )
  true
end

#labelObject



26
27
28
# File 'app/models/hr_lite/payroll_run.rb', line 26

def label
  period_month.strftime("%B %Y")
end

#publish!(actor:) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/models/hr_lite/payroll_run.rb', line 61

def publish!(actor:)
  raise_unless %w[finalized]
  update!(status: "published", published_at: Time.current, published_by_id: actor.id)

  slips = salary_slips.includes(:user).to_a
  Notifications.publish(
    "payroll.published",
    title: "Your salary slip for #{label} is ready",
    body: "Open Earthly HR to view or download it.",
    path: "/salary_slips",
    bell_to: slips.map(&:user),
    email_to: slips.map(&:user)
  )
  true
end

#total_deductionsObject



79
# File 'app/models/hr_lite/payroll_run.rb', line 79

def total_deductions = sum_slips(:total_deductions)

#total_employer_costObject



82
83
84
85
86
# File 'app/models/hr_lite/payroll_run.rb', line 82

def total_employer_cost
  salary_slips.sum(BigDecimal(0)) do |slip|
    slip.employer_costs_hash.values.sum(BigDecimal(0)) { |v| Money.d(v) }
  end
end

#total_grossObject

Ruby-side aggregates (amounts are encrypted — no SQL sums).



78
# File 'app/models/hr_lite/payroll_run.rb', line 78

def total_gross = sum_slips(:gross_earnings)

#total_netObject



80
# File 'app/models/hr_lite/payroll_run.rb', line 80

def total_net = sum_slips(:net_pay)

#unlock!(actor:) ⇒ Object



55
56
57
58
59
# File 'app/models/hr_lite/payroll_run.rb', line 55

def unlock!(actor:)
  raise_unless %w[finalized]
  update!(status: "review")
  true
end