test(explore): #1500 regression fixtures for budget allocation (CG-6)
Two permanent fixtures pinning the failure mode from issue #1500 — explore spending its byte envelope on files that merely name-collide with the query. BOTH FAIL TODAY, by design: they document the bug and become the pass gate for CG-10 (scoring) + CG-12 (proportional allocation). __tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case, entered from an HTTP route. Half the generated tree carries ORDINARY names detectable only by their `// Code generated ... DO NOT EDIT.` header (the #1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the path-detectable channel. BuildPayslip, Upsert and Store each exist twice, generated and hand-written. cycle.go sits above the whole-file window so it clips; the generated files sit below it so they ship whole. Asking "how does payroll cycle create and calculate payslips?" — naming none of the answering symbols — the generated CRUD delivers 57.4% of the envelope against the hand-written layer's 25.6%, all of the latter domain types. cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the hard ceiling drops its whole section. runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert never reach the agent. The second fixture is this repo, "how does explore allocate its output budget across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's 18.5% despite scoring 4.6x lower. It reads the live index, so its assertions are relative rather than fixed percentages. - scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe, driving the CG-4 diagnostic through a JSONL sidecar so it measures the shipping allocator. Fixture entries are hermetic (copy + re-index per run, verified byte-identical across runs); exits 1 while any assertion fails. - scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with the 2026-08-03 baselines. - __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green today; the allocation assertions held as `it.fails` so the suite stays green while the bug is open and goes RED the moment it is fixed. Also documented and deliberately left unfixed: runPayrollCycleAll's `s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the hand-written one — same-name method resolution across two packages picks the wrong receiver. It is upstream of the allocation bug, so it belongs with CG-10's scoring work. Refs #1500 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
16e17495f4
commit
bd86ad2061
@@ -0,0 +1,159 @@
|
||||
package payroll
|
||||
|
||||
import "time"
|
||||
|
||||
// CycleStatus is the lifecycle state of a payroll cycle.
|
||||
type CycleStatus string
|
||||
|
||||
const (
|
||||
CycleOpen CycleStatus = "open"
|
||||
CycleClosed CycleStatus = "closed"
|
||||
)
|
||||
|
||||
// ContractKind distinguishes the two pay models this service supports.
|
||||
type ContractKind string
|
||||
|
||||
const (
|
||||
ContractSalaried ContractKind = "salaried"
|
||||
ContractHourly ContractKind = "hourly"
|
||||
)
|
||||
|
||||
// LineKind separates the two halves of a payslip.
|
||||
type LineKind string
|
||||
|
||||
const (
|
||||
LineEarning LineKind = "earning"
|
||||
LineDeduction LineKind = "deduction"
|
||||
)
|
||||
|
||||
// Cycle is one payroll period.
|
||||
type Cycle struct {
|
||||
ID string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Status CycleStatus
|
||||
ClosedAt time.Time
|
||||
ReopenReason string
|
||||
}
|
||||
|
||||
// Line is a single earning or deduction on a payslip.
|
||||
type Line struct {
|
||||
Code string
|
||||
Kind LineKind
|
||||
AmountCents int64
|
||||
}
|
||||
|
||||
// Payslip is what a cycle produces for one employee.
|
||||
type Payslip struct {
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Currency string
|
||||
PeriodFrom time.Time
|
||||
PeriodTo time.Time
|
||||
Lines []Line
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
Underwater bool
|
||||
RunAt time.Time
|
||||
RunReason string
|
||||
}
|
||||
|
||||
// Timesheet is the approved unit count backing an hourly payslip.
|
||||
type Timesheet struct {
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Approved bool
|
||||
Units int
|
||||
}
|
||||
|
||||
// Allowance is a recurring earning attached to a contract.
|
||||
type Allowance struct {
|
||||
Code string
|
||||
AmountCents int64
|
||||
Prorated bool
|
||||
}
|
||||
|
||||
// Contract holds the pay terms for one employee.
|
||||
type Contract struct {
|
||||
Kind ContractKind
|
||||
Currency string
|
||||
RateCents int64
|
||||
PeriodRateCents int64
|
||||
OvertimeThresholdUnits int
|
||||
OvertimeMultiplier float64
|
||||
Allowances []Allowance
|
||||
StartsOn time.Time
|
||||
EndsOn time.Time
|
||||
}
|
||||
|
||||
// OverlapsWindow reports whether the contract is live at any point in the window.
|
||||
func (c Contract) OverlapsWindow(from, to time.Time) bool {
|
||||
if !c.StartsOn.IsZero() && c.StartsOn.After(to) {
|
||||
return false
|
||||
}
|
||||
if !c.EndsOn.IsZero() && c.EndsOn.Before(from) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PeriodUnits is the contractual unit count for a window, used when a salaried
|
||||
// employee has no approved timesheet.
|
||||
func (c Contract) PeriodUnits(from, to time.Time) int {
|
||||
if to.Before(from) {
|
||||
return 0
|
||||
}
|
||||
days := int(to.Sub(from).Hours()/24) + 1
|
||||
return days * 8
|
||||
}
|
||||
|
||||
// Deduction is a fixed or proportional subtraction from gross.
|
||||
type Deduction struct {
|
||||
Code string
|
||||
FixedCents int64
|
||||
RateBasisPoints int
|
||||
}
|
||||
|
||||
// AmountFor resolves a deduction against a gross amount.
|
||||
func (d Deduction) AmountFor(grossCents int64) int64 {
|
||||
if d.FixedCents > 0 {
|
||||
return d.FixedCents
|
||||
}
|
||||
return grossCents * int64(d.RateBasisPoints) / 10000
|
||||
}
|
||||
|
||||
// TaxBand is one slice of a progressive tax schedule.
|
||||
type TaxBand struct {
|
||||
UpToCents int64
|
||||
RateBasisPoints int
|
||||
}
|
||||
|
||||
// Leave is an absence window.
|
||||
type Leave struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
Unpaid bool
|
||||
}
|
||||
|
||||
// Employee is the payroll view of a person.
|
||||
type Employee struct {
|
||||
ID string
|
||||
Contract Contract
|
||||
Deductions []Deduction
|
||||
TaxBands []TaxBand
|
||||
Leave []Leave
|
||||
}
|
||||
|
||||
// UnpaidLeaveCoversWindow reports whether unpaid leave swallows the whole window.
|
||||
func (e Employee) UnpaidLeaveCoversWindow(from, to time.Time) bool {
|
||||
for _, l := range e.Leave {
|
||||
if !l.Unpaid {
|
||||
continue
|
||||
}
|
||||
if !l.From.After(from) && !l.To.Before(to) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/employee/contract.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/employee
|
||||
|
||||
package employee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContractRow is the generated row type for table contract.
|
||||
type ContractRow struct {
|
||||
ID string
|
||||
EmployeeID string
|
||||
Kind string
|
||||
Currency string
|
||||
RateCents int64
|
||||
PeriodRateCents int64
|
||||
StartsOn time.Time
|
||||
EndsOn time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ContractCreateRequest is the generated create payload for table contract.
|
||||
type ContractCreateRequest struct {
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Kind string `json:"kind"`
|
||||
Currency string `json:"currency"`
|
||||
RateCents int64 `json:"rateCents"`
|
||||
PeriodRateCents int64 `json:"periodRateCents"`
|
||||
StartsOn time.Time `json:"startsOn"`
|
||||
}
|
||||
|
||||
// CreateContract inserts one contract row.
|
||||
func CreateContract(ctx context.Context, db *sql.DB, req ContractCreateRequest) (ContractRow, error) {
|
||||
const q = `INSERT INTO contract (employee_id, kind, currency, rate_cents, period_rate_cents, starts_on)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
|
||||
return scanContract(db.QueryRowContext(ctx, q, req.EmployeeID, req.Kind, req.Currency,
|
||||
req.RateCents, req.PeriodRateCents, req.StartsOn))
|
||||
}
|
||||
|
||||
// GetContract selects one contract row by primary key.
|
||||
func GetContract(ctx context.Context, db *sql.DB, id string) (ContractRow, error) {
|
||||
const q = `SELECT * FROM contract WHERE id = $1`
|
||||
return scanContract(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// DeleteContract removes one contract row.
|
||||
func DeleteContract(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM contract WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListContractsForEmployee selects contract rows for one employee.
|
||||
func ListContractsForEmployee(ctx context.Context, db *sql.DB, employeeID string) ([]ContractRow, error) {
|
||||
const q = `SELECT * FROM contract WHERE employee_id = $1 ORDER BY starts_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q, employeeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ContractRow
|
||||
for rows.Next() {
|
||||
var r ContractRow
|
||||
if err := rows.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
|
||||
&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanContract(row *sql.Row) (ContractRow, error) {
|
||||
var r ContractRow
|
||||
err := row.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
|
||||
&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/employee/employee.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/employee
|
||||
|
||||
package employee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmployeeRow is the generated row type for table employee.
|
||||
type EmployeeRow struct {
|
||||
ID string
|
||||
Email string
|
||||
FullName string
|
||||
Status string
|
||||
HiredOn time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// EmployeeCreateRequest is the generated create payload for table employee.
|
||||
type EmployeeCreateRequest struct {
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// EmployeeUpdateRequest is the generated update payload for table employee.
|
||||
type EmployeeUpdateRequest struct {
|
||||
FullName *string `json:"fullName,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// CreateEmployee inserts one employee row.
|
||||
func CreateEmployee(ctx context.Context, db *sql.DB, req EmployeeCreateRequest) (EmployeeRow, error) {
|
||||
const q = `INSERT INTO employee (email, full_name, status) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, req.Email, req.FullName, req.Status))
|
||||
}
|
||||
|
||||
// GetEmployee selects one employee row by primary key.
|
||||
func GetEmployee(ctx context.Context, db *sql.DB, id string) (EmployeeRow, error) {
|
||||
const q = `SELECT * FROM employee WHERE id = $1`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdateEmployee patches one employee row.
|
||||
func UpdateEmployee(ctx context.Context, db *sql.DB, id string, req EmployeeUpdateRequest) (EmployeeRow, error) {
|
||||
const q = `UPDATE employee SET full_name = COALESCE($2, full_name), status = COALESCE($3, status),
|
||||
updated_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanEmployee(db.QueryRowContext(ctx, q, id, req.FullName, req.Status))
|
||||
}
|
||||
|
||||
// DeleteEmployee removes one employee row.
|
||||
func DeleteEmployee(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM employee WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListEmployeesByStatus selects employee rows in one status.
|
||||
func ListEmployeesByStatus(ctx context.Context, db *sql.DB, status string) ([]EmployeeRow, error) {
|
||||
const q = `SELECT * FROM employee WHERE status = $1 ORDER BY full_name`
|
||||
rows, err := db.QueryContext(ctx, q, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EmployeeRow
|
||||
for rows.Next() {
|
||||
var r EmployeeRow
|
||||
if err := rows.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanEmployee(row *sql.Row) (EmployeeRow, error) {
|
||||
var r EmployeeRow
|
||||
err := row.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/aggregates.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// PayrollCycleTotals is the generated aggregate row for a payroll cycle.
|
||||
type PayrollCycleTotals struct {
|
||||
CycleID string
|
||||
Payslips int64
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
}
|
||||
|
||||
// CalculatePayrollCycleTotals runs the generated SUM aggregate over the
|
||||
// payslip rows of one cycle. It totals what is already stored; it does not
|
||||
// calculate any payslip.
|
||||
func CalculatePayrollCycleTotals(ctx context.Context, db *sql.DB, cycleID string) (PayrollCycleTotals, error) {
|
||||
const q = `SELECT count(*), COALESCE(sum(gross_cents), 0), COALESCE(sum(deduction_cents), 0),
|
||||
COALESCE(sum(net_cents), 0)
|
||||
FROM payslip WHERE cycle_id = $1`
|
||||
var t PayrollCycleTotals
|
||||
t.CycleID = cycleID
|
||||
err := db.QueryRowContext(ctx, q, cycleID).Scan(&t.Payslips, &t.GrossCents, &t.DeductionCents, &t.NetCents)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// CalculatePayslipNet recomputes net from the stored gross and deduction
|
||||
// columns of one row. Pure column arithmetic — no pay rules.
|
||||
func CalculatePayslipNet(row PayslipRow) int64 {
|
||||
return row.GrossCents - row.DeductionCents
|
||||
}
|
||||
|
||||
// CalculatePayrollCycleAverage averages the stored net over a cycle.
|
||||
func CalculatePayrollCycleAverage(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
|
||||
totals, err := CalculatePayrollCycleTotals(ctx, db, cycleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if totals.Payslips == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return totals.NetCents / totals.Payslips, nil
|
||||
}
|
||||
|
||||
// CalculateEmployeeYearToDate sums an employee's stored payslips for a year.
|
||||
func CalculateEmployeeYearToDate(ctx context.Context, db *sql.DB, employeeID string, year int) (int64, error) {
|
||||
const q = `SELECT COALESCE(sum(net_cents), 0) FROM payslip
|
||||
WHERE employee_id = $1 AND extract(year from period_from) = $2`
|
||||
var n int64
|
||||
err := db.QueryRowContext(ctx, q, employeeID, year).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import "time"
|
||||
|
||||
// PayslipDTO is the generated wire representation of a payslip row.
|
||||
type PayslipDTO struct {
|
||||
ID string `json:"id"`
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Currency string `json:"currency"`
|
||||
PeriodFrom time.Time `json:"periodFrom"`
|
||||
PeriodTo time.Time `json:"periodTo"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
DeductionCents int64 `json:"deductionCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// PayrollCycleDTO is the generated wire representation of a payroll_cycle row.
|
||||
type PayrollCycleDTO struct {
|
||||
ID string `json:"id"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PayslipListDTO is the generated list envelope for payslip rows.
|
||||
type PayslipListDTO struct {
|
||||
Items []PayslipDTO `json:"items"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// PayslipToDTO converts a payslip row to its wire form.
|
||||
func PayslipToDTO(r PayslipRow) PayslipDTO {
|
||||
return PayslipDTO{
|
||||
ID: r.ID,
|
||||
CycleID: r.CycleID,
|
||||
EmployeeID: r.EmployeeID,
|
||||
Currency: r.Currency,
|
||||
PeriodFrom: r.PeriodFrom,
|
||||
PeriodTo: r.PeriodTo,
|
||||
GrossCents: r.GrossCents,
|
||||
DeductionCents: r.DeductionCents,
|
||||
NetCents: r.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
// PayslipFromDTO converts a wire payslip back to a row.
|
||||
func PayslipFromDTO(d PayslipDTO) PayslipRow {
|
||||
return PayslipRow{
|
||||
ID: d.ID,
|
||||
CycleID: d.CycleID,
|
||||
EmployeeID: d.EmployeeID,
|
||||
Currency: d.Currency,
|
||||
PeriodFrom: d.PeriodFrom,
|
||||
PeriodTo: d.PeriodTo,
|
||||
GrossCents: d.GrossCents,
|
||||
DeductionCents: d.DeductionCents,
|
||||
NetCents: d.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
// PayrollCycleToDTO converts a payroll_cycle row to its wire form.
|
||||
func PayrollCycleToDTO(r PayrollCycleRow) PayrollCycleDTO {
|
||||
return PayrollCycleDTO{ID: r.ID, Start: r.Start, End: r.End, Status: r.Status}
|
||||
}
|
||||
|
||||
// PayslipsToListDTO wraps payslip rows in the generated list envelope.
|
||||
func PayslipsToListDTO(rows []PayslipRow, total int64) PayslipListDTO {
|
||||
items := make([]PayslipDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, PayslipToDTO(r))
|
||||
}
|
||||
return PayslipListDTO{Items: items, Total: total}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/payroll_cycle.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PayrollCycleRow is the generated row type for table payroll_cycle.
|
||||
type PayrollCycleRow struct {
|
||||
ID string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Status string
|
||||
ClosedAt time.Time
|
||||
ReopenReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PayrollCycleCreateRequest is the generated create payload for payroll_cycle.
|
||||
type PayrollCycleCreateRequest struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PayrollCycleUpdateRequest is the generated update payload for payroll_cycle.
|
||||
type PayrollCycleUpdateRequest struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
ReopenReason *string `json:"reopenReason,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePayrollCycle inserts one payroll_cycle row.
|
||||
func CreatePayrollCycle(ctx context.Context, db *sql.DB, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
|
||||
const q = `INSERT INTO payroll_cycle (start_on, end_on, status) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, req.Start, req.End, req.Status))
|
||||
}
|
||||
|
||||
// GetPayrollCycle selects one payroll_cycle row by primary key.
|
||||
func GetPayrollCycle(ctx context.Context, db *sql.DB, id string) (PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle WHERE id = $1`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdatePayrollCycle patches one payroll_cycle row.
|
||||
func UpdatePayrollCycle(ctx context.Context, db *sql.DB, id string, req PayrollCycleUpdateRequest) (PayrollCycleRow, error) {
|
||||
const q = `UPDATE payroll_cycle SET status = COALESCE($2, status),
|
||||
reopen_reason = COALESCE($3, reopen_reason), updated_at = now()
|
||||
WHERE id = $1 RETURNING *`
|
||||
return scanPayrollCycle(db.QueryRowContext(ctx, q, id, req.Status, req.ReopenReason))
|
||||
}
|
||||
|
||||
// DeletePayrollCycle removes one payroll_cycle row.
|
||||
func DeletePayrollCycle(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM payroll_cycle WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPayrollCycles selects every payroll_cycle row.
|
||||
func ListPayrollCycles(ctx context.Context, db *sql.DB) ([]PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle ORDER BY start_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayrollCycleRow
|
||||
for rows.Next() {
|
||||
var r PayrollCycleRow
|
||||
if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListPayrollCyclesByStatus selects payroll_cycle rows in one status.
|
||||
func ListPayrollCyclesByStatus(ctx context.Context, db *sql.DB, status string) ([]PayrollCycleRow, error) {
|
||||
const q = `SELECT * FROM payroll_cycle WHERE status = $1 ORDER BY start_on DESC`
|
||||
rows, err := db.QueryContext(ctx, q, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayrollCycleRow
|
||||
for rows.Next() {
|
||||
var r PayrollCycleRow
|
||||
if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BuildPayrollCycle maps a create request onto a row.
|
||||
func BuildPayrollCycle(req PayrollCycleCreateRequest) PayrollCycleRow {
|
||||
return PayrollCycleRow{Start: req.Start, End: req.End, Status: req.Status}
|
||||
}
|
||||
|
||||
func scanPayrollCycle(row *sql.Row) (PayrollCycleRow, error) {
|
||||
var r PayrollCycleRow
|
||||
err := row.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
|
||||
&r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll/payslip.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PayslipRow is the generated row type for table payslip.
|
||||
type PayslipRow struct {
|
||||
ID string
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Currency string
|
||||
PeriodFrom time.Time
|
||||
PeriodTo time.Time
|
||||
GrossCents int64
|
||||
DeductionCents int64
|
||||
NetCents int64
|
||||
Underwater bool
|
||||
RunAt time.Time
|
||||
RunReason string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PayslipCreateRequest is the generated create payload for table payslip.
|
||||
type PayslipCreateRequest struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Currency string `json:"currency"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
DeductionCents int64 `json:"deductionCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// PayslipUpdateRequest is the generated update payload for table payslip.
|
||||
type PayslipUpdateRequest struct {
|
||||
GrossCents *int64 `json:"grossCents,omitempty"`
|
||||
DeductionCents *int64 `json:"deductionCents,omitempty"`
|
||||
NetCents *int64 `json:"netCents,omitempty"`
|
||||
RunReason *string `json:"runReason,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePayslip inserts one payslip row.
|
||||
func CreatePayslip(ctx context.Context, db *sql.DB, req PayslipCreateRequest) (PayslipRow, error) {
|
||||
const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
|
||||
row := db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Currency, req.GrossCents, req.DeductionCents, req.NetCents)
|
||||
return scanPayslip(row)
|
||||
}
|
||||
|
||||
// GetPayslip selects one payslip row by primary key.
|
||||
func GetPayslip(ctx context.Context, db *sql.DB, id string) (PayslipRow, error) {
|
||||
const q = `SELECT * FROM payslip WHERE id = $1`
|
||||
return scanPayslip(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// UpdatePayslip patches one payslip row.
|
||||
func UpdatePayslip(ctx context.Context, db *sql.DB, id string, req PayslipUpdateRequest) (PayslipRow, error) {
|
||||
const q = `UPDATE payslip SET gross_cents = COALESCE($2, gross_cents),
|
||||
deduction_cents = COALESCE($3, deduction_cents),
|
||||
net_cents = COALESCE($4, net_cents),
|
||||
run_reason = COALESCE($5, run_reason),
|
||||
updated_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanPayslip(db.QueryRowContext(ctx, q, id, req.GrossCents, req.DeductionCents, req.NetCents, req.RunReason))
|
||||
}
|
||||
|
||||
// DeletePayslip removes one payslip row.
|
||||
func DeletePayslip(ctx context.Context, db *sql.DB, id string) error {
|
||||
const q = `DELETE FROM payslip WHERE id = $1`
|
||||
_, err := db.ExecContext(ctx, q, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPayslipsByCycle selects every payslip row for a cycle.
|
||||
func ListPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]PayslipRow, error) {
|
||||
const q = `SELECT * FROM payslip WHERE cycle_id = $1 ORDER BY employee_id`
|
||||
rows, err := db.QueryContext(ctx, q, cycleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PayslipRow
|
||||
for rows.Next() {
|
||||
var r PayslipRow
|
||||
if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
|
||||
&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountPayslipsByCycle counts payslip rows for a cycle.
|
||||
func CountPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
|
||||
const q = `SELECT count(*) FROM payslip WHERE cycle_id = $1`
|
||||
var n int64
|
||||
err := db.QueryRowContext(ctx, q, cycleID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BuildPayslip maps a create request onto a row. Field copy only — the
|
||||
// generator has no knowledge of pay rules.
|
||||
func BuildPayslip(req PayslipCreateRequest) PayslipRow {
|
||||
return PayslipRow{
|
||||
CycleID: req.CycleID,
|
||||
EmployeeID: req.EmployeeID,
|
||||
Currency: req.Currency,
|
||||
GrossCents: req.GrossCents,
|
||||
DeductionCents: req.DeductionCents,
|
||||
NetCents: req.NetCents,
|
||||
}
|
||||
}
|
||||
|
||||
func scanPayslip(row *sql.Row) (PayslipRow, error) {
|
||||
var r PayslipRow
|
||||
err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
|
||||
&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
|
||||
&r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/payroll
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/payroll
|
||||
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Store is the generated repository over every payroll table.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore returns a generated store bound to db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// Upsert writes one payslip row, keyed by (cycle_id, employee_id).
|
||||
func (s *Store) Upsert(ctx context.Context, row PayslipRow) (PayslipRow, error) {
|
||||
const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (cycle_id, employee_id) DO UPDATE SET
|
||||
gross_cents = EXCLUDED.gross_cents,
|
||||
deduction_cents = EXCLUDED.deduction_cents,
|
||||
net_cents = EXCLUDED.net_cents,
|
||||
updated_at = now()
|
||||
RETURNING *`
|
||||
return scanPayslip(s.db.QueryRowContext(ctx, q, row.CycleID, row.EmployeeID, row.Currency,
|
||||
row.GrossCents, row.DeductionCents, row.NetCents))
|
||||
}
|
||||
|
||||
// UpsertPayrollCycle writes one payroll_cycle row, keyed by id.
|
||||
func (s *Store) UpsertPayrollCycle(ctx context.Context, row PayrollCycleRow) (PayrollCycleRow, error) {
|
||||
const q = `INSERT INTO payroll_cycle (id, start_on, end_on, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = now()
|
||||
RETURNING *`
|
||||
return scanPayrollCycle(s.db.QueryRowContext(ctx, q, row.ID, row.Start, row.End, row.Status))
|
||||
}
|
||||
|
||||
// CreatePayslip inserts one payslip row through the store.
|
||||
func (s *Store) CreatePayslip(ctx context.Context, req PayslipCreateRequest) (PayslipRow, error) {
|
||||
return CreatePayslip(ctx, s.db, req)
|
||||
}
|
||||
|
||||
// GetPayslip reads one payslip row through the store.
|
||||
func (s *Store) GetPayslip(ctx context.Context, id string) (PayslipRow, error) {
|
||||
return GetPayslip(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// UpdatePayslip patches one payslip row through the store.
|
||||
func (s *Store) UpdatePayslip(ctx context.Context, id string, req PayslipUpdateRequest) (PayslipRow, error) {
|
||||
return UpdatePayslip(ctx, s.db, id, req)
|
||||
}
|
||||
|
||||
// DeletePayslip removes one payslip row through the store.
|
||||
func (s *Store) DeletePayslip(ctx context.Context, id string) error {
|
||||
return DeletePayslip(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// ListPayslipsByCycle lists payslip rows for a cycle through the store.
|
||||
func (s *Store) ListPayslipsByCycle(ctx context.Context, cycleID string) ([]PayslipRow, error) {
|
||||
return ListPayslipsByCycle(ctx, s.db, cycleID)
|
||||
}
|
||||
|
||||
// CreatePayrollCycle inserts one payroll_cycle row through the store.
|
||||
func (s *Store) CreatePayrollCycle(ctx context.Context, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
|
||||
return CreatePayrollCycle(ctx, s.db, req)
|
||||
}
|
||||
|
||||
// GetPayrollCycle reads one payroll_cycle row through the store.
|
||||
func (s *Store) GetPayrollCycle(ctx context.Context, id string) (PayrollCycleRow, error) {
|
||||
return GetPayrollCycle(ctx, s.db, id)
|
||||
}
|
||||
|
||||
// ListPayrollCycles lists payroll_cycle rows through the store.
|
||||
func (s *Store) ListPayrollCycles(ctx context.Context) ([]PayrollCycleRow, error) {
|
||||
return ListPayrollCycles(ctx, s.db)
|
||||
}
|
||||
|
||||
// Tx runs fn inside a transaction.
|
||||
func (s *Store) Tx(ctx context.Context, fn func(*Store) error) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(s); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Code generated by fkit v3.11.0. DO NOT EDIT.
|
||||
//
|
||||
// Source: schema/timesheet/timesheet.fkit
|
||||
// Regenerate with: go run ./tools/fkitgen ./schema/timesheet
|
||||
|
||||
package timesheet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimesheetRow is the generated row type for table timesheet.
|
||||
type TimesheetRow struct {
|
||||
ID string
|
||||
CycleID string
|
||||
EmployeeID string
|
||||
Units int
|
||||
Approved bool
|
||||
ApprovedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TimesheetCreateRequest is the generated create payload for table timesheet.
|
||||
type TimesheetCreateRequest struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
EmployeeID string `json:"employeeId"`
|
||||
Units int `json:"units"`
|
||||
}
|
||||
|
||||
// CreateTimesheet inserts one timesheet row.
|
||||
func CreateTimesheet(ctx context.Context, db *sql.DB, req TimesheetCreateRequest) (TimesheetRow, error) {
|
||||
const q = `INSERT INTO timesheet (cycle_id, employee_id, units) VALUES ($1, $2, $3) RETURNING *`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Units))
|
||||
}
|
||||
|
||||
// GetTimesheet selects one timesheet row by primary key.
|
||||
func GetTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
|
||||
const q = `SELECT * FROM timesheet WHERE id = $1`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// ApproveTimesheet flips the approved column on one timesheet row.
|
||||
func ApproveTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
|
||||
const q = `UPDATE timesheet SET approved = true, approved_at = now() WHERE id = $1 RETURNING *`
|
||||
return scanTimesheet(db.QueryRowContext(ctx, q, id))
|
||||
}
|
||||
|
||||
// ListTimesheetsByCycle selects timesheet rows for one cycle.
|
||||
func ListTimesheetsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]TimesheetRow, error) {
|
||||
const q = `SELECT * FROM timesheet WHERE cycle_id = $1 ORDER BY employee_id`
|
||||
rows, err := db.QueryContext(ctx, q, cycleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TimesheetRow
|
||||
for rows.Next() {
|
||||
var r TimesheetRow
|
||||
if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
|
||||
&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanTimesheet(row *sql.Row) (TimesheetRow, error) {
|
||||
var r TimesheetRow
|
||||
err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
|
||||
&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.27.1
|
||||
// source: payroll/v1/payroll.proto
|
||||
|
||||
package payrollpb
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// RunPayrollCycleRequest is the generated request message.
|
||||
type RunPayrollCycleRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
DryRun bool `protobuf:"varint,2,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"`
|
||||
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetCycleId() string {
|
||||
if x != nil {
|
||||
return x.CycleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetDryRun() bool {
|
||||
if x != nil {
|
||||
return x.DryRun
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleRequest) Reset() { *x = RunPayrollCycleRequest{} }
|
||||
func (x *RunPayrollCycleRequest) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// RunPayrollCycleResponse is the generated response message.
|
||||
type RunPayrollCycleResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
Payslips []*Payslip `protobuf:"bytes,2,rep,name=payslips,proto3" json:"payslips,omitempty"`
|
||||
GrossCents int64 `protobuf:"varint,3,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
|
||||
NetCents int64 `protobuf:"varint,4,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleResponse) GetPayslips() []*Payslip {
|
||||
if x != nil {
|
||||
return x.Payslips
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *RunPayrollCycleResponse) Reset() { *x = RunPayrollCycleResponse{} }
|
||||
func (x *RunPayrollCycleResponse) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// Payslip is the generated payslip message.
|
||||
type Payslip struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
CycleId string `protobuf:"bytes,2,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
|
||||
EmployeeId string `protobuf:"bytes,3,opt,name=employee_id,json=employeeId,proto3" json:"employee_id,omitempty"`
|
||||
GrossCents int64 `protobuf:"varint,4,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
|
||||
DeductionCents int64 `protobuf:"varint,5,opt,name=deduction_cents,json=deductionCents,proto3" json:"deduction_cents,omitempty"`
|
||||
NetCents int64 `protobuf:"varint,6,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
|
||||
PeriodFrom *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=period_from,json=periodFrom,proto3" json:"period_from,omitempty"`
|
||||
PeriodTo *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=period_to,json=periodTo,proto3" json:"period_to,omitempty"`
|
||||
}
|
||||
|
||||
func (x *Payslip) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Payslip) GetNetCents() int64 {
|
||||
if x != nil {
|
||||
return x.NetCents
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Payslip) Reset() { *x = Payslip{} }
|
||||
func (x *Payslip) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
// PayrollCycle is the generated cycle message.
|
||||
type PayrollCycle struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Start *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start,proto3" json:"start,omitempty"`
|
||||
End *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end,proto3" json:"end,omitempty"`
|
||||
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PayrollCycle) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PayrollCycle) Reset() { *x = PayrollCycle{} }
|
||||
func (x *PayrollCycle) String() string { return protoimpl.X.MessageStringOf(x) }
|
||||
|
||||
var file_payroll_v1_payroll_proto_rawDesc = []byte{
|
||||
0x0a, 0x18, 0x70, 0x61, 0x79, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x61, 0x79,
|
||||
0x72, 0x6f, 0x6c, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x70, 0x61, 0x79, 0x72,
|
||||
}
|
||||
|
||||
var file_payroll_v1_payroll_proto_goTypes = []any{
|
||||
(*RunPayrollCycleRequest)(nil),
|
||||
(*RunPayrollCycleResponse)(nil),
|
||||
(*Payslip)(nil),
|
||||
(*PayrollCycle)(nil),
|
||||
}
|
||||
|
||||
var File_payroll_v1_payroll_proto protoreflect.FileDescriptor
|
||||
@@ -0,0 +1,104 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.4.0
|
||||
// - protoc v5.27.1
|
||||
// source: payroll/v1/payroll.proto
|
||||
|
||||
package payrollpb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
PayrollService_RunPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/RunPayrollCycle"
|
||||
PayrollService_GetPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/GetPayrollCycle"
|
||||
PayrollService_ListPayslips_FullMethodName = "/payroll.v1.PayrollService/ListPayslips"
|
||||
)
|
||||
|
||||
// PayrollServiceClient is the generated client API for PayrollService.
|
||||
type PayrollServiceClient interface {
|
||||
RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
|
||||
GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error)
|
||||
ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
|
||||
}
|
||||
|
||||
type payrollServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
// NewPayrollServiceClient returns a generated client.
|
||||
func NewPayrollServiceClient(cc grpc.ClientConnInterface) PayrollServiceClient {
|
||||
return &payrollServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
|
||||
out := new(RunPayrollCycleResponse)
|
||||
err := c.cc.Invoke(ctx, PayrollService_RunPayrollCycle_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error) {
|
||||
out := new(PayrollCycle)
|
||||
err := c.cc.Invoke(ctx, PayrollService_GetPayrollCycle_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *payrollServiceClient) ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
|
||||
out := new(RunPayrollCycleResponse)
|
||||
err := c.cc.Invoke(ctx, PayrollService_ListPayslips_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PayrollServiceServer is the generated server API for PayrollService.
|
||||
type PayrollServiceServer interface {
|
||||
RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
|
||||
GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error)
|
||||
ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
|
||||
mustEmbedUnimplementedPayrollServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedPayrollServiceServer must be embedded for forward compatibility.
|
||||
type UnimplementedPayrollServiceServer struct{}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (UnimplementedPayrollServiceServer) mustEmbedUnimplementedPayrollServiceServer() {}
|
||||
|
||||
// RegisterPayrollServiceServer registers the generated service.
|
||||
func RegisterPayrollServiceServer(s grpc.ServiceRegistrar, srv PayrollServiceServer) {
|
||||
s.RegisterService(&PayrollService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
// PayrollService_ServiceDesc is the generated service descriptor.
|
||||
var PayrollService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "payroll.v1.PayrollService",
|
||||
HandlerType: (*PayrollServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "RunPayrollCycle"},
|
||||
{MethodName: "GetPayrollCycle"},
|
||||
{MethodName: "ListPayslips"},
|
||||
},
|
||||
Metadata: "payroll/v1/payroll.proto",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package clock
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock is the time seam so a payroll run is reproducible in tests.
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// System is the production clock.
|
||||
type System struct{}
|
||||
|
||||
func (System) Now() time.Time { return time.Now().UTC() }
|
||||
|
||||
// Fixed is a frozen clock.
|
||||
type Fixed struct{ At time.Time }
|
||||
|
||||
func (f Fixed) Now() time.Time { return f.At }
|
||||
@@ -0,0 +1,119 @@
|
||||
package payslipstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// Store is the hand-written persistence seam the use-case layer writes through.
|
||||
// It is deliberately narrow: the generated fkit store can address every table,
|
||||
// this one only exposes the operations a payroll cycle needs.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
payslips map[string]payroll.Payslip
|
||||
cycles map[string]payroll.Cycle
|
||||
employees map[string][]payroll.Employee
|
||||
sheets map[string]payroll.Timesheet
|
||||
}
|
||||
|
||||
func New() *Store {
|
||||
return &Store{
|
||||
payslips: map[string]payroll.Payslip{},
|
||||
cycles: map[string]payroll.Cycle{},
|
||||
employees: map[string][]payroll.Employee{},
|
||||
sheets: map[string]payroll.Timesheet{},
|
||||
}
|
||||
}
|
||||
|
||||
func key(cycleID, employeeID string) string { return cycleID + "/" + employeeID }
|
||||
|
||||
// Upsert writes a payslip, replacing any prior slip for the same
|
||||
// (cycle, employee). A re-run of a cycle must not duplicate rows, so this is
|
||||
// an upsert rather than an insert.
|
||||
func (s *Store) Upsert(ctx context.Context, slip payroll.Payslip) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if slip.CycleID == "" || slip.EmployeeID == "" {
|
||||
return fmt.Errorf("payslip missing cycle or employee id")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.payslips[key(slip.CycleID, slip.EmployeeID)] = slip
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByCycle returns every payslip a cycle produced.
|
||||
func (s *Store) ListByCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]payroll.Payslip, 0, len(s.payslips))
|
||||
for _, slip := range s.payslips {
|
||||
if slip.CycleID == cycleID {
|
||||
out = append(out, slip)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Cycle{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cycle, ok := s.cycles[cycleID]
|
||||
if !ok {
|
||||
return payroll.Cycle{}, fmt.Errorf("cycle %s not found", cycleID)
|
||||
}
|
||||
return cycle, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveCycle(ctx context.Context, cycle payroll.Cycle) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cycles[cycle.ID] = cycle
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) EmployeesForCycle(ctx context.Context, cycleID string) ([]payroll.Employee, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.employees[cycleID], nil
|
||||
}
|
||||
|
||||
func (s *Store) Timesheet(ctx context.Context, cycleID, employeeID string) (payroll.Timesheet, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Timesheet{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ts, ok := s.sheets[key(cycleID, employeeID)]
|
||||
if !ok {
|
||||
return payroll.Timesheet{}, fmt.Errorf("no timesheet for %s in %s", employeeID, cycleID)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// Seed loads fixture data; the real service reads from Postgres.
|
||||
func (s *Store) Seed(cycle payroll.Cycle, employees []payroll.Employee, sheets []payroll.Timesheet) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cycles[cycle.ID] = cycle
|
||||
s.employees[cycle.ID] = employees
|
||||
for _, ts := range sheets {
|
||||
s.sheets[key(ts.CycleID, ts.EmployeeID)] = ts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/example/payroll-svc/internal/usecase/payroll"
|
||||
)
|
||||
|
||||
// PayrollHandler is the HTTP entry point into the payroll use-case layer.
|
||||
type PayrollHandler struct {
|
||||
svc *payroll.Service
|
||||
}
|
||||
|
||||
func NewPayrollHandler(svc *payroll.Service) *PayrollHandler {
|
||||
return &PayrollHandler{svc: svc}
|
||||
}
|
||||
|
||||
type runCycleRequest struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type runCycleResponse struct {
|
||||
CycleID string `json:"cycleId"`
|
||||
Payslips int `json:"payslips"`
|
||||
GrossCents int64 `json:"grossCents"`
|
||||
NetCents int64 `json:"netCents"`
|
||||
}
|
||||
|
||||
// RunCycle kicks off a payroll cycle: it hands the cycle id to the use-case
|
||||
// layer, which builds and persists a payslip per active employee.
|
||||
func (h *PayrollHandler) RunCycle(w http.ResponseWriter, r *http.Request) {
|
||||
cycleID := r.PathValue("cycleID")
|
||||
if cycleID == "" {
|
||||
httpError(w, http.StatusBadRequest, "cycleID is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req runCycleRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "malformed body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.svc.RunCycle(r.Context(), cycleID, payroll.RunOptions{
|
||||
DryRun: req.DryRun,
|
||||
Reason: req.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, payroll.ErrCycleClosed) {
|
||||
httpError(w, http.StatusConflict, "cycle already closed")
|
||||
return
|
||||
}
|
||||
httpError(w, http.StatusInternalServerError, "run failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, runCycleResponse{
|
||||
CycleID: result.CycleID,
|
||||
Payslips: len(result.Payslips),
|
||||
GrossCents: result.TotalGrossCents,
|
||||
NetCents: result.TotalNetCents,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PayrollHandler) GetCycle(w http.ResponseWriter, r *http.Request) {
|
||||
cycle, err := h.svc.Cycle(r.Context(), r.PathValue("cycleID"))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusNotFound, "no such cycle")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cycle)
|
||||
}
|
||||
|
||||
func (h *PayrollHandler) ListPayslips(w http.ResponseWriter, r *http.Request) {
|
||||
slips, err := h.svc.PayslipsForCycle(r.Context(), r.PathValue("cycleID"))
|
||||
if err != nil {
|
||||
httpError(w, http.StatusNotFound, "no such cycle")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, slips)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// NewRouter wires the HTTP surface. The payroll cycle endpoint is the only
|
||||
// entry point into the hand-written use-case layer.
|
||||
func NewRouter(h *PayrollHandler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle)
|
||||
mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}", h.GetCycle)
|
||||
mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}/payslips", h.ListPayslips)
|
||||
mux.HandleFunc("GET /healthz", health)
|
||||
return mux
|
||||
}
|
||||
|
||||
func health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// BuildPayslip turns one employee's contract and timesheet into a complete
|
||||
// payslip for the cycle: base pay, overtime, allowances, then deductions and
|
||||
// tax, in that order. Every amount is in integer cents; nothing here rounds
|
||||
// until the final net, so a cent never disappears between two lines.
|
||||
//
|
||||
// This is the calculation the generated CRUD layer does NOT do — fkit's
|
||||
// BuildPayslip only copies fields between a DTO and a row.
|
||||
func (s *Service) BuildPayslip(
|
||||
ctx context.Context,
|
||||
cycle payroll.Cycle,
|
||||
employee payroll.Employee,
|
||||
timesheet payroll.Timesheet,
|
||||
) (payroll.Payslip, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return payroll.Payslip{}, err
|
||||
}
|
||||
if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID {
|
||||
return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID)
|
||||
}
|
||||
|
||||
slip := payroll.Payslip{
|
||||
CycleID: cycle.ID,
|
||||
EmployeeID: employee.ID,
|
||||
Currency: employee.Contract.Currency,
|
||||
PeriodFrom: cycle.Start,
|
||||
PeriodTo: cycle.End,
|
||||
}
|
||||
|
||||
base := s.basePayCents(employee, cycle, timesheet)
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "BASE", Kind: payroll.LineEarning, AmountCents: base,
|
||||
})
|
||||
|
||||
if overtime := s.overtimeCents(employee, timesheet); overtime > 0 {
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime,
|
||||
})
|
||||
}
|
||||
|
||||
for _, allowance := range employee.Contract.Allowances {
|
||||
amount := prorateAllowance(allowance, cycle, employee)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning)
|
||||
|
||||
for _, d := range employee.Deductions {
|
||||
amount := d.AmountFor(slip.GrossCents)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount,
|
||||
})
|
||||
}
|
||||
|
||||
tax, err := s.taxCents(employee, slip.GrossCents)
|
||||
if err != nil {
|
||||
return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err)
|
||||
}
|
||||
slip.Lines = append(slip.Lines, payroll.Line{
|
||||
Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax,
|
||||
})
|
||||
|
||||
slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction)
|
||||
slip.NetCents = slip.GrossCents - slip.DeductionCents
|
||||
if slip.NetCents < 0 {
|
||||
slip.NetCents = 0
|
||||
slip.Underwater = true
|
||||
}
|
||||
|
||||
return slip, nil
|
||||
}
|
||||
|
||||
// basePayCents is the contractual pay for the period: salaried staff get the
|
||||
// period rate prorated across their contract window, hourly staff get rate ×
|
||||
// approved units.
|
||||
func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 {
|
||||
switch e.Contract.Kind {
|
||||
case payroll.ContractSalaried:
|
||||
full := e.Contract.PeriodRateCents
|
||||
return prorateSalary(full, e.Contract, cycle)
|
||||
case payroll.ContractHourly:
|
||||
return e.Contract.RateCents * int64(ts.Units)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// overtimeCents pays approved units above the contractual threshold at the
|
||||
// contract's overtime multiplier.
|
||||
func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 {
|
||||
if e.Contract.Kind != payroll.ContractHourly {
|
||||
return 0
|
||||
}
|
||||
threshold := e.Contract.OvertimeThresholdUnits
|
||||
if threshold <= 0 || ts.Units <= threshold {
|
||||
return 0
|
||||
}
|
||||
extra := int64(ts.Units - threshold)
|
||||
return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra))
|
||||
}
|
||||
|
||||
// taxCents applies the employee's tax band schedule to the gross.
|
||||
func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) {
|
||||
if len(e.TaxBands) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var tax int64
|
||||
remaining := gross
|
||||
for _, band := range e.TaxBands {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 {
|
||||
return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints)
|
||||
}
|
||||
slice := remaining
|
||||
if band.UpToCents > 0 && slice > band.UpToCents {
|
||||
slice = band.UpToCents
|
||||
}
|
||||
tax += slice * int64(band.RateBasisPoints) / 10000
|
||||
remaining -= slice
|
||||
}
|
||||
return tax, nil
|
||||
}
|
||||
|
||||
func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 {
|
||||
var total int64
|
||||
for _, l := range lines {
|
||||
if l.Kind == kind {
|
||||
total += l.AmountCents
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package payroll
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/example/payroll-svc/internal/domain/payroll"
|
||||
)
|
||||
|
||||
// prorateSalary scales a full period rate down when the contract covers only
|
||||
// part of the cycle window (a mid-period joiner or leaver).
|
||||
func prorateSalary(fullCents int64, contract payroll.Contract, cycle payroll.Cycle) int64 {
|
||||
window := calendarDays(cycle.Start, cycle.End)
|
||||
if window <= 0 {
|
||||
return 0
|
||||
}
|
||||
covered := calendarDays(laterOf(cycle.Start, contract.StartsOn), earlierOf(cycle.End, contract.EndsOn))
|
||||
if covered >= window {
|
||||
return fullCents
|
||||
}
|
||||
if covered <= 0 {
|
||||
return 0
|
||||
}
|
||||
return fullCents * int64(covered) / int64(window)
|
||||
}
|
||||
|
||||
// prorateAllowance applies the same window rule to a recurring allowance.
|
||||
func prorateAllowance(a payroll.Allowance, cycle payroll.Cycle, e payroll.Employee) int64 {
|
||||
if !a.Prorated {
|
||||
return a.AmountCents
|
||||
}
|
||||
return prorateSalary(a.AmountCents, e.Contract, cycle)
|
||||
}
|
||||
|
||||
func calendarDays(from, to time.Time) int {
|
||||
if to.Before(from) {
|
||||
return 0
|
||||
}
|
||||
return int(to.Sub(from).Hours()/24) + 1
|
||||
}
|
||||
|
||||
func laterOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func earlierOf(a, b time.Time) time.Time {
|
||||
if b.IsZero() || a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user