| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- package payroll
- import (
- "context"
- "errors"
- "fmt"
- "sort"
- "time"
- "github.com/example/payroll-svc/internal/domain/payroll"
- "github.com/example/payroll-svc/internal/platform/clock"
- "github.com/example/payroll-svc/internal/store/payslipstore"
- )
- // ErrCycleClosed is returned when a cycle has already been finalized.
- var ErrCycleClosed = errors.New("payroll cycle is closed")
- // ErrNoEmployees is returned when a cycle resolves to an empty roster.
- var ErrNoEmployees = errors.New("payroll cycle has no active employees")
- // RunOptions tunes a single run of a payroll cycle.
- type RunOptions struct {
- // DryRun computes every payslip but persists nothing.
- DryRun bool
- // Reason is recorded on the audit trail for re-runs.
- Reason string
- // Only, when non-empty, restricts the run to these employee ids.
- Only []string
- }
- // RunResult is the outcome of one payroll cycle run.
- type RunResult struct {
- CycleID string
- Payslips []payroll.Payslip
- TotalGrossCents int64
- TotalNetCents int64
- Skipped []string
- FinishedAt time.Time
- }
- // Service is the hand-written payroll use-case layer. It owns the order of
- // operations for a cycle: resolve the roster, build a payslip per employee,
- // then persist. The generated CRUD layer under internal/gen has no opinion
- // about any of that — it can only read and write single rows.
- type Service struct {
- store *payslipstore.Store
- clock clock.Clock
- }
- func NewService(store *payslipstore.Store, c clock.Clock) *Service {
- return &Service{store: store, clock: c}
- }
- // RunCycle is the public entry point used by the HTTP handler. It loads the
- // cycle, guards its state, and delegates the actual work to runPayrollCycleAll.
- func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) {
- cycle, err := s.loadCycle(ctx, cycleID)
- if err != nil {
- return RunResult{}, err
- }
- if cycle.Status == payroll.CycleClosed {
- return RunResult{}, ErrCycleClosed
- }
- roster, err := s.rosterFor(ctx, cycle, opts)
- if err != nil {
- return RunResult{}, err
- }
- if len(roster) == 0 {
- return RunResult{}, ErrNoEmployees
- }
- return s.runPayrollCycleAll(ctx, cycle, roster, opts)
- }
- // runPayrollCycleAll is the heart of the cycle: for every employee on the
- // roster it builds a payslip from that employee's contract and timesheet,
- // then upserts the result. Ordering matters — a payslip is only persisted
- // after every earning, deduction and tax line has been resolved, so a
- // partially-computed slip can never reach the store.
- func (s *Service) runPayrollCycleAll(
- ctx context.Context,
- cycle payroll.Cycle,
- roster []payroll.Employee,
- opts RunOptions,
- ) (RunResult, error) {
- result := RunResult{CycleID: cycle.ID}
- now := s.clock.Now()
- for _, employee := range roster {
- if err := ctx.Err(); err != nil {
- return result, err
- }
- timesheet, err := s.timesheetFor(ctx, cycle, employee)
- if err != nil {
- result.Skipped = append(result.Skipped, employee.ID)
- continue
- }
- slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet)
- if err != nil {
- return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err)
- }
- slip.RunAt = now
- slip.RunReason = opts.Reason
- if !opts.DryRun {
- if err := s.store.Upsert(ctx, slip); err != nil {
- return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err)
- }
- }
- result.Payslips = append(result.Payslips, slip)
- result.TotalGrossCents += slip.GrossCents
- result.TotalNetCents += slip.NetCents
- }
- if !opts.DryRun {
- if err := s.closeCycle(ctx, cycle, now); err != nil {
- return result, err
- }
- }
- sort.Slice(result.Payslips, func(i, j int) bool {
- return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID
- })
- result.FinishedAt = now
- return result, nil
- }
- // rosterFor resolves which employees this cycle pays. An employee joins the
- // roster when their contract overlaps the cycle window and they are not on
- // unpaid leave for the whole period.
- func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) {
- all, err := s.store.EmployeesForCycle(ctx, cycle.ID)
- if err != nil {
- return nil, err
- }
- only := map[string]bool{}
- for _, id := range opts.Only {
- only[id] = true
- }
- roster := make([]payroll.Employee, 0, len(all))
- for _, e := range all {
- if len(only) > 0 && !only[e.ID] {
- continue
- }
- if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) {
- continue
- }
- if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) {
- continue
- }
- roster = append(roster, e)
- }
- sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID })
- return roster, nil
- }
- func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) {
- ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID)
- if err != nil {
- return payroll.Timesheet{}, err
- }
- if ts.Approved {
- return ts, nil
- }
- if e.Contract.Kind == payroll.ContractSalaried {
- // Salaried staff are paid the contractual period regardless of an
- // unapproved timesheet; hourly staff are skipped until approval.
- return payroll.Timesheet{
- CycleID: cycle.ID,
- EmployeeID: e.ID,
- Approved: true,
- Units: e.Contract.PeriodUnits(cycle.Start, cycle.End),
- }, nil
- }
- return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID)
- }
- func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
- if cycleID == "" {
- return payroll.Cycle{}, errors.New("empty cycle id")
- }
- return s.store.Cycle(ctx, cycleID)
- }
- func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error {
- cycle.Status = payroll.CycleClosed
- cycle.ClosedAt = at
- return s.store.SaveCycle(ctx, cycle)
- }
- // Cycle exposes a cycle for the read endpoints.
- func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
- return s.loadCycle(ctx, cycleID)
- }
- // PayslipsForCycle lists the payslips a completed cycle produced.
- func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
- slips, err := s.store.ListByCycle(ctx, cycleID)
- if err != nil {
- return nil, err
- }
- sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID })
- return slips, nil
- }
- // Reopen unwinds a closed cycle so it can be re-run after a correction.
- func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error {
- cycle, err := s.loadCycle(ctx, cycleID)
- if err != nil {
- return err
- }
- if cycle.Status != payroll.CycleClosed {
- return nil
- }
- cycle.Status = payroll.CycleOpen
- cycle.ReopenReason = reason
- cycle.ClosedAt = time.Time{}
- return s.store.SaveCycle(ctx, cycle)
- }
|