payslip_builder.go 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. package payroll
  2. import (
  3. "context"
  4. "fmt"
  5. "github.com/example/payroll-svc/internal/domain/payroll"
  6. )
  7. // BuildPayslip turns one employee's contract and timesheet into a complete
  8. // payslip for the cycle: base pay, overtime, allowances, then deductions and
  9. // tax, in that order. Every amount is in integer cents; nothing here rounds
  10. // until the final net, so a cent never disappears between two lines.
  11. //
  12. // This is the calculation the generated CRUD layer does NOT do — fkit's
  13. // BuildPayslip only copies fields between a DTO and a row.
  14. func (s *Service) BuildPayslip(
  15. ctx context.Context,
  16. cycle payroll.Cycle,
  17. employee payroll.Employee,
  18. timesheet payroll.Timesheet,
  19. ) (payroll.Payslip, error) {
  20. if err := ctx.Err(); err != nil {
  21. return payroll.Payslip{}, err
  22. }
  23. if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID {
  24. return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID)
  25. }
  26. slip := payroll.Payslip{
  27. CycleID: cycle.ID,
  28. EmployeeID: employee.ID,
  29. Currency: employee.Contract.Currency,
  30. PeriodFrom: cycle.Start,
  31. PeriodTo: cycle.End,
  32. }
  33. base := s.basePayCents(employee, cycle, timesheet)
  34. slip.Lines = append(slip.Lines, payroll.Line{
  35. Code: "BASE", Kind: payroll.LineEarning, AmountCents: base,
  36. })
  37. if overtime := s.overtimeCents(employee, timesheet); overtime > 0 {
  38. slip.Lines = append(slip.Lines, payroll.Line{
  39. Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime,
  40. })
  41. }
  42. for _, allowance := range employee.Contract.Allowances {
  43. amount := prorateAllowance(allowance, cycle, employee)
  44. if amount == 0 {
  45. continue
  46. }
  47. slip.Lines = append(slip.Lines, payroll.Line{
  48. Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount,
  49. })
  50. }
  51. slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning)
  52. for _, d := range employee.Deductions {
  53. amount := d.AmountFor(slip.GrossCents)
  54. if amount == 0 {
  55. continue
  56. }
  57. slip.Lines = append(slip.Lines, payroll.Line{
  58. Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount,
  59. })
  60. }
  61. tax, err := s.taxCents(employee, slip.GrossCents)
  62. if err != nil {
  63. return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err)
  64. }
  65. slip.Lines = append(slip.Lines, payroll.Line{
  66. Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax,
  67. })
  68. slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction)
  69. slip.NetCents = slip.GrossCents - slip.DeductionCents
  70. if slip.NetCents < 0 {
  71. slip.NetCents = 0
  72. slip.Underwater = true
  73. }
  74. return slip, nil
  75. }
  76. // basePayCents is the contractual pay for the period: salaried staff get the
  77. // period rate prorated across their contract window, hourly staff get rate ×
  78. // approved units.
  79. func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 {
  80. switch e.Contract.Kind {
  81. case payroll.ContractSalaried:
  82. full := e.Contract.PeriodRateCents
  83. return prorateSalary(full, e.Contract, cycle)
  84. case payroll.ContractHourly:
  85. return e.Contract.RateCents * int64(ts.Units)
  86. default:
  87. return 0
  88. }
  89. }
  90. // overtimeCents pays approved units above the contractual threshold at the
  91. // contract's overtime multiplier.
  92. func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 {
  93. if e.Contract.Kind != payroll.ContractHourly {
  94. return 0
  95. }
  96. threshold := e.Contract.OvertimeThresholdUnits
  97. if threshold <= 0 || ts.Units <= threshold {
  98. return 0
  99. }
  100. extra := int64(ts.Units - threshold)
  101. return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra))
  102. }
  103. // taxCents applies the employee's tax band schedule to the gross.
  104. func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) {
  105. if len(e.TaxBands) == 0 {
  106. return 0, nil
  107. }
  108. var tax int64
  109. remaining := gross
  110. for _, band := range e.TaxBands {
  111. if remaining <= 0 {
  112. break
  113. }
  114. if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 {
  115. return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints)
  116. }
  117. slice := remaining
  118. if band.UpToCents > 0 && slice > band.UpToCents {
  119. slice = band.UpToCents
  120. }
  121. tax += slice * int64(band.RateBasisPoints) / 10000
  122. remaining -= slice
  123. }
  124. return tax, nil
  125. }
  126. func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 {
  127. var total int64
  128. for _, l := range lines {
  129. if l.Kind == kind {
  130. total += l.AmountCents
  131. }
  132. }
  133. return total
  134. }