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

processing is allowed back in so a run stranded there by a killed process (deploy roll, timeout) can be recomputed instead of blocking that month forever.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'app/models/hr_lite/payroll_run.rb', line 35

def compute!(actor:)
  raise_unless %w[draft review processing]
  previous = status

  begin
    update!(status: "processing")
    PayrollRunProcessor.call(self)
    update!(status: "review", processed_at: Time.current)
    true
  rescue => e
    # Restore what the run WAS. Hardcoding "draft" here used to demote a
    # finalized or published run — which then exposed the delete-draft
    # control, and that cascades over every salary slip.
    update_columns(status: previous) # rubocop:disable Rails/SkipsModelValidations
    raise e
  end
end

#editable?Boolean

Returns:

  • (Boolean)


24
25
26
# File 'app/models/hr_lite/payroll_run.rb', line 24

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

#finalize!(actor:) ⇒ Object

Raises:

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


53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/models/hr_lite/payroll_run.rb', line 53

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



28
29
30
# File 'app/models/hr_lite/payroll_run.rb', line 28

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

#publish!(actor:) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'app/models/hr_lite/payroll_run.rb', line 72

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
  # Everyone is emailed — a final settlement matters most to the person who
  # has left — but a bell is only useful to someone who can still sign in,
  # and offboarding revokes that. Sending one pointed at a page they cannot
  # open is just noise in a place they will never look.
  exited = EmployeeProfile.where(user_id: slips.map(&:user_id))
                          .where(date_of_exit: ..Date.current)
                          .pluck(:user_id).to_set

  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.reject { |slip| exited.include?(slip.user_id) }.map(&:user),
    email_to: slips.map(&:user)
  )
  true
end

#total_deductionsObject



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

def total_deductions = sum_slips(:total_deductions)

#total_employer_costObject



101
102
103
104
105
# File 'app/models/hr_lite/payroll_run.rb', line 101

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).



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

def total_gross = sum_slips(:gross_earnings)

#total_netObject



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

def total_net = sum_slips(:net_pay)

#unlock!(actor:) ⇒ Object



66
67
68
69
70
# File 'app/models/hr_lite/payroll_run.rb', line 66

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