Class: HrLite::SlipBuilder
- Inherits:
-
Object
- Object
- HrLite::SlipBuilder
- Defined in:
- app/services/hr_lite/slip_builder.rb
Overview
Assembles one employee's slip numbers for a run. Sequence: attendance summary -> proration -> PF -> ESI -> PT -> TDS -> totals. Every line is rounded by its own statutory rule exactly once; net pay is a plain subtraction of already-rounded lines, so nothing can drift.
Class Method Summary collapse
Instance Method Summary collapse
- #call ⇒ Object
-
#initialize(run, user, structure, profile, lop_override, tds_override) ⇒ SlipBuilder
constructor
A new instance of SlipBuilder.
Constructor Details
#initialize(run, user, structure, profile, lop_override, tds_override) ⇒ SlipBuilder
Returns a new instance of SlipBuilder.
11 12 13 14 15 16 17 18 19 |
# File 'app/services/hr_lite/slip_builder.rb', line 11 def initialize(run, user, structure, profile, lop_override, tds_override) @run = run @user = user @structure = structure @profile = profile @lop_override = lop_override @tds_override = tds_override @rates = StatutoryRateCard.for(run.period_month) end |
Class Method Details
.call(run:, user:, structure:, profile:, lop_override: nil, tds_override: nil) ⇒ Object
7 8 9 |
# File 'app/services/hr_lite/slip_builder.rb', line 7 def self.call(run:, user:, structure:, profile:, lop_override: nil, tds_override: nil) new(run, user, structure, profile, lop_override, tds_override).call end |
Instance Method Details
#call ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'app/services/hr_lite/slip_builder.rb', line 21 def call summary = AttendanceSummary.for(user: @user, month: @run.period_month) days_in_month = summary[:days_in_month] # Clamped defensively: a stored negative override (written before the # controller bounded them) would otherwise pay more days than the month has. lop = [ Money.d(@lop_override || summary[:lop_days]), BigDecimal(0) ].max # `upcoming` is normally zero — PayrollRun refuses a month that has not # ended — but subtracting it means a legacy run for an open month cannot # pay for days nobody has worked yet. payable = [ Money.d(days_in_month) - Money.d(summary[:out_of_window]) - Money.d(summary[:upcoming]) - lop, Money.d(0) ].max earnings = Calculators::Proration.call( structure: @structure, payable_days: payable, days_in_month: days_in_month ) gross_earned = earnings.sum(BigDecimal(0)) { |row| row[:amount] } basic_earned = earnings.find { |row| row[:code] == "basic" }&.fetch(:amount) || BigDecimal(0) deductions = [] employer_costs = {} if @structure.pf_applicable pf = Calculators::Pf.call(basic_earned: basic_earned, on_full_basic: @structure.pf_on_full_basic, rates: @rates[:pf]) deductions << { code: "pf_employee", label: "Provident fund", amount: pf.employee, meta: { pf_wage: pf.pf_wage.to_s("F") } } employer_costs.merge!( pf_eps: pf.employer_eps, pf_epf: pf.employer_epf, edli: pf.edli, pf_admin: pf.admin_charges ) end esi = Calculators::Esi.call(monthly_gross: esi_reference_gross, gross_earned: gross_earned, applicable: @structure.esi_applicable, rates: @rates[:esi]) if esi.applicable? deductions << { code: "esi_employee", label: "ESI", amount: esi.employee } employer_costs[:esi_employer] = esi.employer end pt = Calculators::ProfessionalTax.call(state: @structure.pt_state, gross_earned: gross_earned, period_month: @run.period_month, rates: @rates[:pt]) deductions << { code: "pt", label: "Professional tax", amount: pt } if pt.positive? fy = SalarySlip.fy_to_date(@user, @run.period_month) tds = Calculators::Tds.call( regime: @profile.tax_regime, structure_monthly_gross: @structure.monthly_gross, gross_earned_this_month: gross_earned, # Plus anything paid this FY that this install never ran — a previous # employer, or the months before payroll was switched on. Without it a # mid-year start projects those months as zero income and can land the # whole year under the rebate cap. fy_gross_paid: fy[:gross] + Money.d(@profile.fy_opening_gross), fy_tds_paid: fy[:tds] + Money.d(@profile.fy_opening_tds), months_remaining: months_remaining_in_fy, declared_annual_deductions: @profile.declared_annual_deductions, rates: @rates[:income_tax], override: @tds_override ) deductions << { code: "tds", label: "Income tax (TDS)", amount: tds.monthly } if tds.monthly.positive? total_deductions = deductions.sum(BigDecimal(0)) { |row| row[:amount] } { period_month: @run.period_month, days_in_month: days_in_month, payable_days: payable, # Recorded so the slip can say WHY a mid-month joiner's payable days # are short, instead of looking like days went missing. out_of_window_days: Money.d(summary[:out_of_window]), lop_days: Money.d(summary[:lop_days]), lop_override: @lop_override, tds_override: @tds_override, earnings: serialize_rows(earnings), deductions: serialize_rows(deductions), employer_costs: employer_costs.transform_values { |v| v.to_s("F") }.to_json, tax_details: tds.details.to_json, gross_earnings: gross_earned, total_deductions: total_deductions, # A heavy-LOP month can leave TDS (projected from the full structure) # larger than what was actually earned. Nobody is paid a negative # salary; PayrollRunProcessor warns when this floor bites. net_pay: [ gross_earned - total_deductions, BigDecimal(0) ].max, computed_at: Time.current } end |