Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save iamajvillalobos/e2dc13619a14663bdda3f93c634af7ce to your computer and use it in GitHub Desktop.

Select an option

Save iamajvillalobos/e2dc13619a14663bdda3f93c634af7ce to your computer and use it in GitHub Desktop.
payroll_generator_service.rb
# frozen_string_literal: true
# ABOUTME: Generates payroll entries for all active employees in a payroll period
# ABOUTME: Orchestrates data preloading and PayrollEntryBuilder calls
class PayrollGeneratorService
Result = Struct.new(:success?, :generated_count, :failed, :total_count, :blocked_by_incomplete, keyword_init: true)
def initialize(payroll_period)
@payroll_period = payroll_period
@account = payroll_period.account
end
def call
return blocked_result if blocked_by_incomplete_entries?
results = generate_all_entries
recalculate_totals_if_needed(results)
build_result(results)
end
private
attr_reader :payroll_period, :account
def blocked_by_incomplete_entries?
!incomplete_detector.can_generate_payroll?
end
def blocked_result
Result.new(
success?: false,
generated_count: 0,
failed: [],
total_count: 0,
blocked_by_incomplete: incomplete_detector.call,
)
end
def incomplete_detector
@incomplete_detector ||= IncompleteTimeEntriesDetector.new(payroll_period)
end
def generate_all_entries
results = { success: [], failed: [] }
employees.each do |employee|
generate_entry_for(employee, results)
end
results
end
def generate_entry_for(employee, results)
entry = build_payroll_entry(employee)
track_result(employee, entry, results)
rescue StandardError => e
track_exception(employee, e, results)
end
def build_payroll_entry(employee)
PayrollEntryBuilder.new(
employee,
payroll_period,
holidays_by_date: holidays_by_date,
prior_cutoff_period: prior_cutoff_period,
rest_day_schedules: rest_day_schedules[employee.id],
).call
end
def track_result(employee, entry, results)
if entry.persisted?
results[:success] << employee
else
results[:failed] << { employee: employee, errors: entry.errors.full_messages }
log_entry_failure(employee, entry)
end
end
def track_exception(employee, exception, results)
results[:failed] << { employee: employee, exception: exception }
log_exception(employee, exception)
end
def log_entry_failure(employee, entry)
Rails.logger.error(
"Failed to generate payroll for employee #{employee.id} (#{employee.name}): " \
"#{entry.errors.full_messages.join(", ")}",
)
end
def log_exception(employee, exception)
Rails.logger.error(
"Exception generating payroll for employee #{employee.id} (#{employee.name}): " \
"#{exception.message}\n#{exception.backtrace.first(5).join("\n")}",
)
end
def recalculate_totals_if_needed(results)
payroll_period.recalculate_totals! if results[:success].any?
end
def build_result(results)
Result.new(
success?: results[:failed].empty?,
generated_count: results[:success].count,
failed: results[:failed],
total_count: employees.count,
blocked_by_incomplete: nil,
)
end
def employees
@employees ||= account.employees.active
end
def holidays_by_date
@holidays_by_date ||= Holiday
.where(date: payroll_period.start_date..payroll_period.end_date)
.index_by(&:date)
end
def prior_cutoff_period
return @prior_cutoff_period if defined?(@prior_cutoff_period)
@prior_cutoff_period = find_prior_cutoff_period
end
def find_prior_cutoff_period
return unless payroll_period.semi_monthly? && payroll_period.semi_monthly_cutoff_second?
PayrollPeriod
.where(account: account)
.where(period_type: :semi_monthly, semi_monthly_cutoff: :first)
.where(
"start_date >= ? AND end_date <= ?",
payroll_period.start_date.beginning_of_month,
payroll_period.end_date.end_of_month,
)
.first
end
def rest_day_schedules
@rest_day_schedules ||= Schedule
.where(account_id: account.id)
.where(off_day: true)
.covering_range(payroll_period.start_date, payroll_period.end_date)
.group_by(&:employee_id)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment