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:
Colby McHenry
2026-08-03 23:30:17 -05:00
co-authored by Claude Opus 5
parent 16e17495f4
commit bd86ad2061
25 changed files with 2687 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
# payroll-go — the #1500 regression fixture
A synthetic Go service reproducing the repo shape from [issue #1500](https://github.com/colbymchenry/codegraph/issues/1500):
**generated CRUD sitting beside the hand-written use-case that does the real work.**
This tree is a fixture, not a program. It never compiles or runs — it exists to be
indexed. Keep it valid, idiomatic Go anyway: the extractor's output is the whole point.
## The shape
```
cmd/payrolld/main.go wires the service
internal/transport/httpapi/ HTTP entry point → use-case
internal/usecase/payroll/ ← THE ANSWER. Hand-written workflow:
cycle.go runPayrollCycleAll (227 lines)
payslip_builder.go BuildPayslip — the actual pay calculation
prorate.go
internal/domain/payroll/payslip.go hand-written domain types
internal/store/payslipstore/store.go the real Upsert
internal/platform/clock/clock.go
internal/gen/fkit/payroll/ ← THE NOISE. Generated CRUD, ORDINARY names:
payslip.go CreatePayslip, GetPayslip, UpdatePayslip, a second BuildPayslip
payroll_cycle.go CreatePayrollCycle, PayrollCycleCreateRequest, …
store.go a second Upsert
calculate.go CalculatePayrollCycleTotals, CalculatePayslipNet, …
dto.go
internal/gen/fkit/employee/, timesheet/ more generated CRUD
internal/gen/payrollpb/*.pb.go generated, detectable by PATH
```
The chain the fixture is built around is `runPayrollCycleAll` → `BuildPayslip` → `Upsert`,
entered from `POST /v1/payroll/cycles/{cycleID}/run`.
## The three properties that make it a regression fixture
1. **Generated files that only a CONTENT header betrays.** The `internal/gen/fkit/**`
files have ordinary names (`payslip.go`, `store.go`) and carry
`// Code generated by fkit v3.11.0. DO NOT EDIT.`. Path-only detection misses every
one of them — that is the #1500 case, and why CG-5 added the content check. The
`payrollpb/*.pb.go` files cover the path-detectable channel beside them.
2. **Deliberate name collisions.** `BuildPayslip`, `Upsert` and `Store` each exist twice,
once generated and once hand-written. The generated layer also name-collides on every
term of the question below — `CreatePayslip`, `PayrollCycleCreateRequest`,
`CalculatePayrollCycleTotals` — so a scorer that rewards incidental name matches
surfaces the CRUD path.
3. **A size split that drives the render mode.** `cycle.go` is deliberately over the
whole-file window (227 lines) so it falls through to clipped clusters; the generated
files are deliberately under it so they ship whole. Allocation follows file size, not
relevance. `__tests__/explore-allocation-1500.test.ts` pins both sides of that split —
if you edit these files, keep it.
## The assertion
Query: **"how does payroll cycle create and calculate payslips?"** — an architecture
question that names none of the symbols that answer it. The budget should concentrate on
the hand-written workflow. As of 2026-08-03 it does not:
| | allocated | delivered |
|---|---|---|
| hand-written | 48.4% | **25.6%** (all of it domain types) |
| generated CRUD | 39.9% | **57.4%** |
`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers
**zero** — the hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never
renders at all. `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real
`Upsert` never reach the agent.
## Running it
```bash
npm run build
node scripts/agent-eval/probe-allocation.mjs payroll-go # exits 1 today, by design
npx vitest run __tests__/explore-allocation-1500.test.ts # green today, by design
```
The probe reports per-file budget share against `scripts/agent-eval/allocation-fixtures.json`.
The vitest suite pins the fixture's shape and holds the allocation assertion as `it.fails` —
green while the bug is open, red the moment it is fixed. See
`docs/design/explore-budget-allocation.md`.
## Known finding: the chain's `Upsert` edge resolves to the generated store
`runPayrollCycleAll` calls `s.store.Upsert(ctx, slip)`, where `s.store` is a
`*payslipstore.Store`. The graph resolves that edge to `internal/gen/fkit/payroll/store.go`
— the **generated** `Store.Upsert` — not the hand-written one. Same-name method resolution
across two packages that both define `Store.Upsert` picks the wrong receiver.
This is a resolution defect, not a budget one, and it is left unfixed on purpose: it is
upstream of the allocation bug (a wrong edge pulls the generated store into the subgraph
and inflates its score), so it belongs with the scoring work in CG-10 rather than here.
The test asserts only that the workflow reaches *an* `Upsert`, so tightening the resolver
later will not break the fixture.
@@ -0,0 +1,35 @@
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/example/payroll-svc/internal/platform/clock"
"github.com/example/payroll-svc/internal/store/payslipstore"
"github.com/example/payroll-svc/internal/transport/httpapi"
"github.com/example/payroll-svc/internal/usecase/payroll"
)
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
store := payslipstore.New()
svc := payroll.NewService(store, clock.System{})
router := httpapi.NewRouter(httpapi.NewPayrollHandler(svc))
srv := &http.Server{
Addr: addr,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("payrolld listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("payrolld: %v", err)
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/example/payroll-svc
go 1.22
@@ -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
}