cycle.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. package payroll
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "sort"
  7. "time"
  8. "github.com/example/payroll-svc/internal/domain/payroll"
  9. "github.com/example/payroll-svc/internal/platform/clock"
  10. "github.com/example/payroll-svc/internal/store/payslipstore"
  11. )
  12. // ErrCycleClosed is returned when a cycle has already been finalized.
  13. var ErrCycleClosed = errors.New("payroll cycle is closed")
  14. // ErrNoEmployees is returned when a cycle resolves to an empty roster.
  15. var ErrNoEmployees = errors.New("payroll cycle has no active employees")
  16. // RunOptions tunes a single run of a payroll cycle.
  17. type RunOptions struct {
  18. // DryRun computes every payslip but persists nothing.
  19. DryRun bool
  20. // Reason is recorded on the audit trail for re-runs.
  21. Reason string
  22. // Only, when non-empty, restricts the run to these employee ids.
  23. Only []string
  24. }
  25. // RunResult is the outcome of one payroll cycle run.
  26. type RunResult struct {
  27. CycleID string
  28. Payslips []payroll.Payslip
  29. TotalGrossCents int64
  30. TotalNetCents int64
  31. Skipped []string
  32. FinishedAt time.Time
  33. }
  34. // Service is the hand-written payroll use-case layer. It owns the order of
  35. // operations for a cycle: resolve the roster, build a payslip per employee,
  36. // then persist. The generated CRUD layer under internal/gen has no opinion
  37. // about any of that — it can only read and write single rows.
  38. type Service struct {
  39. store *payslipstore.Store
  40. clock clock.Clock
  41. }
  42. func NewService(store *payslipstore.Store, c clock.Clock) *Service {
  43. return &Service{store: store, clock: c}
  44. }
  45. // RunCycle is the public entry point used by the HTTP handler. It loads the
  46. // cycle, guards its state, and delegates the actual work to runPayrollCycleAll.
  47. func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) {
  48. cycle, err := s.loadCycle(ctx, cycleID)
  49. if err != nil {
  50. return RunResult{}, err
  51. }
  52. if cycle.Status == payroll.CycleClosed {
  53. return RunResult{}, ErrCycleClosed
  54. }
  55. roster, err := s.rosterFor(ctx, cycle, opts)
  56. if err != nil {
  57. return RunResult{}, err
  58. }
  59. if len(roster) == 0 {
  60. return RunResult{}, ErrNoEmployees
  61. }
  62. return s.runPayrollCycleAll(ctx, cycle, roster, opts)
  63. }
  64. // runPayrollCycleAll is the heart of the cycle: for every employee on the
  65. // roster it builds a payslip from that employee's contract and timesheet,
  66. // then upserts the result. Ordering matters — a payslip is only persisted
  67. // after every earning, deduction and tax line has been resolved, so a
  68. // partially-computed slip can never reach the store.
  69. func (s *Service) runPayrollCycleAll(
  70. ctx context.Context,
  71. cycle payroll.Cycle,
  72. roster []payroll.Employee,
  73. opts RunOptions,
  74. ) (RunResult, error) {
  75. result := RunResult{CycleID: cycle.ID}
  76. now := s.clock.Now()
  77. for _, employee := range roster {
  78. if err := ctx.Err(); err != nil {
  79. return result, err
  80. }
  81. timesheet, err := s.timesheetFor(ctx, cycle, employee)
  82. if err != nil {
  83. result.Skipped = append(result.Skipped, employee.ID)
  84. continue
  85. }
  86. slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet)
  87. if err != nil {
  88. return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err)
  89. }
  90. slip.RunAt = now
  91. slip.RunReason = opts.Reason
  92. if !opts.DryRun {
  93. if err := s.store.Upsert(ctx, slip); err != nil {
  94. return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err)
  95. }
  96. }
  97. result.Payslips = append(result.Payslips, slip)
  98. result.TotalGrossCents += slip.GrossCents
  99. result.TotalNetCents += slip.NetCents
  100. }
  101. if !opts.DryRun {
  102. if err := s.closeCycle(ctx, cycle, now); err != nil {
  103. return result, err
  104. }
  105. }
  106. sort.Slice(result.Payslips, func(i, j int) bool {
  107. return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID
  108. })
  109. result.FinishedAt = now
  110. return result, nil
  111. }
  112. // rosterFor resolves which employees this cycle pays. An employee joins the
  113. // roster when their contract overlaps the cycle window and they are not on
  114. // unpaid leave for the whole period.
  115. func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) {
  116. all, err := s.store.EmployeesForCycle(ctx, cycle.ID)
  117. if err != nil {
  118. return nil, err
  119. }
  120. only := map[string]bool{}
  121. for _, id := range opts.Only {
  122. only[id] = true
  123. }
  124. roster := make([]payroll.Employee, 0, len(all))
  125. for _, e := range all {
  126. if len(only) > 0 && !only[e.ID] {
  127. continue
  128. }
  129. if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) {
  130. continue
  131. }
  132. if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) {
  133. continue
  134. }
  135. roster = append(roster, e)
  136. }
  137. sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID })
  138. return roster, nil
  139. }
  140. func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) {
  141. ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID)
  142. if err != nil {
  143. return payroll.Timesheet{}, err
  144. }
  145. if ts.Approved {
  146. return ts, nil
  147. }
  148. if e.Contract.Kind == payroll.ContractSalaried {
  149. // Salaried staff are paid the contractual period regardless of an
  150. // unapproved timesheet; hourly staff are skipped until approval.
  151. return payroll.Timesheet{
  152. CycleID: cycle.ID,
  153. EmployeeID: e.ID,
  154. Approved: true,
  155. Units: e.Contract.PeriodUnits(cycle.Start, cycle.End),
  156. }, nil
  157. }
  158. return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID)
  159. }
  160. func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
  161. if cycleID == "" {
  162. return payroll.Cycle{}, errors.New("empty cycle id")
  163. }
  164. return s.store.Cycle(ctx, cycleID)
  165. }
  166. func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error {
  167. cycle.Status = payroll.CycleClosed
  168. cycle.ClosedAt = at
  169. return s.store.SaveCycle(ctx, cycle)
  170. }
  171. // Cycle exposes a cycle for the read endpoints.
  172. func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
  173. return s.loadCycle(ctx, cycleID)
  174. }
  175. // PayslipsForCycle lists the payslips a completed cycle produced.
  176. func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
  177. slips, err := s.store.ListByCycle(ctx, cycleID)
  178. if err != nil {
  179. return nil, err
  180. }
  181. sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID })
  182. return slips, nil
  183. }
  184. // Reopen unwinds a closed cycle so it can be re-run after a correction.
  185. func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error {
  186. cycle, err := s.loadCycle(ctx, cycleID)
  187. if err != nil {
  188. return err
  189. }
  190. if cycle.Status != payroll.CycleClosed {
  191. return nil
  192. }
  193. cycle.Status = payroll.CycleOpen
  194. cycle.ReopenReason = reason
  195. cycle.ClosedAt = time.Time{}
  196. return s.store.SaveCycle(ctx, cycle)
  197. }