Class: HrLite::PayrollRun
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- HrLite::PayrollRun
- 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
-
#compute!(actor:) ⇒ Object
processingis allowed back in so a run stranded there by a killed process (deploy roll, timeout) can be recomputed instead of blocking that month forever. - #editable? ⇒ Boolean
- #finalize!(actor:) ⇒ Object
- #label ⇒ Object
- #publish!(actor:) ⇒ Object
- #total_deductions ⇒ Object
- #total_employer_cost ⇒ Object
-
#total_gross ⇒ Object
Ruby-side aggregates (amounts are encrypted — no SQL sums).
- #total_net ⇒ Object
- #unlock!(actor:) ⇒ Object
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
24 25 26 |
# File 'app/models/hr_lite/payroll_run.rb', line 24 def editable? draft? || processing? || review? end |
#finalize!(actor:) ⇒ Object
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 |
#label ⇒ Object
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_deductions ⇒ Object
121 |
# File 'app/models/hr_lite/payroll_run.rb', line 121 def total_deductions = sum_slips(:total_deductions) |
#total_employer_cost ⇒ Object
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_gross ⇒ Object
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_net ⇒ Object
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 |