Created
January 9, 2026 12:41
-
-
Save iamajvillalobos/e2dc13619a14663bdda3f93c634af7ce to your computer and use it in GitHub Desktop.
payroll_generator_service.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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