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
52
53
54
55
56
# 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)
    transaction do
      update!(status: "review", processed_at: Time.current)
      audit!("payroll.computed", actor,
             "slips" => salary_slips.count, "warnings" => warnings.length,
             "from_status" => previous)
    end
    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))


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

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

  transaction do
    update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
    # Finalizing freezes every slip in the run. Who did it, to how many
    # people, is the row an investigation starts from.
    audit!("payroll.finalized", actor, "slips" => salary_slips.count)
    book_loan_repayments!
  end
  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



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/models/hr_lite/payroll_run.rb', line 92

def publish!(actor:)
  raise_unless %w[finalized]
  transaction do
    update!(status: "published", published_at: Time.current, published_by_id: actor.id)
    audit!("payroll.published", actor, "slips" => salary_slips.count)
  end

  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



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

def total_deductions = sum_slips(:total_deductions)

#total_employer_costObject



124
125
126
127
128
# File 'app/models/hr_lite/payroll_run.rb', line 124

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



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

def total_gross = sum_slips(:gross_earnings)

#total_netObject



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

def total_net = sum_slips(:net_pay)

#unlock!(actor:) ⇒ Object



77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'app/models/hr_lite/payroll_run.rb', line 77

def unlock!(actor:)
  raise_unless %w[finalized]
  transaction do
    update!(status: "review")
    # The slips are editable again, so the repayments booked at finalize
    # have to come back off the loans — otherwise unlocking and
    # refinalizing takes the instalment twice.
    unbook_loan_repayments!
    # Reopening frozen slips for editing is the single most sensitive
    # transition in the module — it is the one that has to leave a trace.
    audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
  end
  true
end