# Payroll (Port 01 lean core)

Timesheets, absolute overtime rules and immutable CSV exports. HTTP is capability-gated under `/v1/stores/{store}`; see `contracts/fragments/payroll.yaml`. No external payroll provider, webhooks, payroll submission, or production deployment.

## Source fidelity

Read-only reference: BuyerKiosk `Scheduling/Services/OvertimeCalculator.php`, `TimesheetExporter.php`, `WorkWeek.php`, `Controllers/TimesheetController.php`, and the corresponding `tests/Unit/Scheduling` tests. Pure overtime ordering and UTC/local segmentation are transliterated with the same threshold strategy; legacy PDO/controller infrastructure is not copied. Tests use the installed PHPUnit 12 framework rather than adding Pest to this repository.

- Daily highest threshold first; seventh-consecutive-day rules replace that day's standard thresholds; weekly overtime consumes **remaining regular** minutes after daily premiums, with optional daily countable cap. Exempt memberships book all worked time as regular. Weekly premium is attributed chronologically to its eligible days for historical rate calculation.
- US-FLSA fallback (40h), US-CA daily 8h/12h plus 7th-day, CA-ON 44h, and validated custom absolute rules. Schema version 1 uses integer minutes; typed DTO normalizes numeric input. Rules become effective on normalized week labels, latest applicable rule wins. Changes impacting locked history fail 409.
- Shared Scheduling `WorkWeek` is authoritative for labels, containment, and DST-safe next cutoff. Punch timestamps are UTC instants. Closed sessions clip to exact week instants and local midnight; explicit unpaid break overlap is removed, paid breaks stay worked.
- **Integer conversion policy:** accumulate net elapsed seconds per local day, floor to whole minutes once. Apportion those whole minutes to historical rate slices by largest remainder (chronological tie break), so short breaks/rate changes cannot lose paid minutes. Sum integer minute × cents × hundredths-of-multiplier numerators and round cents once. A missing effective rate makes pay unknown/null, never a guessed zero or partial total.
- Scheduled minutes clip assigned shifts to the same week bounds. Actual hours use one engine, so regular + OT + DT is the only actual-minute total (no divergent gross/net engine reconciliation).

### Intentional safety changes from the inspected BK checkout

The checked-out `TimesheetController::exportTimesheets` still generates CSV, marks rows, then records audit; it does **not** contain the newer immutable replay implementation described by the port plan. The requested durable scheme is implemented natively here, not claimed as a literal copy of absent code. The inspected exporter rounds displayed hour components but retains actual earned pay; this module preserves that distinction explicitly as `pay_basis: actual_minutes`.

BK's today-open exemption remains available as descriptive `stranded_clock_ins` metadata, but **every open session overlapping the week hard-blocks approval/export**, including today's and prior-week clock-ins. Otherwise approval could freeze a still-clocked-in worker out of clock-out. Oversized sessions (>16 elapsed hours) flag both overlapping weeks, not only the clock-in week. Punch lookup failures propagate; guards never fail open.

## Lifecycle and authorization

- Pending → approved: locked recalculation, then hard open-session guard and soft oversized acknowledgement. `acknowledge_oversized_sessions: true` never overrides missing clock-outs.
- Approved → pending: explicit unlock. Approved calculation fields cannot be mutated through Eloquent. Pending recalculation never rewrites approved/exported sheets.
- Approved → exported: only within export transaction. Exported sheets cannot be edited, unlocked or deleted through Eloquent. Export models reject update/delete as well. Direct SQL is reserved for migrations/recovery and is not a supported mutation API.
- Managers have `timesheets.approve`, see hours, and **do not receive total_pay_cents**. `/my/timesheets` resolves the real membership and includes only its own pay; supplied membership IDs are ignored. `payroll.export` remains owner-only through the capability matrix (explicit platform-admin policy inherited from StoreViewer).
- All child resources are checked against the path store; foreign children return 404. Lists are bounded: one week for management, latest 52 weeks self, latest 100 export history rows.

## Export durability

`Idempotency-Key` required, 1–128 printable non-space ASCII characters, unique **per store**. The canonical request hash covers sorted requested labels and explicit acknowledgement boolean. Duplicate labels are rejected; up to 26 normalized week labels.

1. Authorize; look up immutable export **before mutable payroll/settings/rate/punch reads**.
2. Existing key: mismatch → 409, otherwise rebuild saved scalar rows and verify SHA-256 before replay. Auth still runs on every request.
3. New key: store lock, deterministic membership locks, pending recalculation, guard all covered weeks. Unapproved worked time cannot silently be omitted. An already-exported week requires downloading its original export, not exporting under a new key.
4. Recalculate approved sheets read-only and compare their pay/hour buckets to approved values. If mutable legacy writes bypassed guards, reject 409 and require unlock/reapprove rather than silently exporting stale pay.
5. Persist immutable export row and mark every covered approved timesheet in **one database transaction**. A post-mark failure rolls back both row and marks (real SQL regression test).
6. Downloads use only saved scalar CSV rows, version check and hash check. Corrupt/unsupported snapshots return 409; no mutable reconstruction. CSV body is never persisted. `conflict_quarantine_key` is reserved for operator recovery: a non-null hold blocks CSV release even when its hash is valid. No automatic repair/release is attempted.

CSV: one row per membership/week; RFC4180 CRLF, explicit empty escape, formula-leading text neutralized. Quantize regular/OT/DT minute components using none/nearest/up/down, format to hundredth hours, then derive displayed total from those displayed components. Summary `total_minutes` is the sum of quantized minute components. **Pay is actual earned pay, not recomputed from display-rounded hours.** Missing rates produce blank CSV pay, null aggregate pay, and explicit `warnings: [missing_pay_rates]` in create/replay/history. Consumers must surface that warning and not treat the file as a complete payable payroll when present. No period-aggregate alternate CSV layout is exposed.

## Integration

See `INTERFACE.md`. Parent's Team event/listener runs pay-rate guard and recalculation in the membership-locked append transaction. Clock owns `PunchPeriodGuard` plus old/new session-range guards and calls recalculation after writes. No new external integration or cross-module observer is implicitly installed by this module.

Deferred: statutory holidays, policy-driven break deductions, payroll provider submission, period aggregate layout, background recalculation/performance indexing beyond current week/member indexes. Full-history punch pairing is intentionally conservative so multi-day sessions cannot disappear at a padded fetch boundary; large histories may need a separately verified bounded-session query optimization.

## Verification commands

Installed dependencies require PHP >=8.4; use `/opt/homebrew/bin/php` (8.5 here), not the available but incompatible 8.3 binary. Focused tests use real isolated SQLite through RefreshDatabase:

```
/opt/homebrew/bin/php vendor/bin/phpunit tests/Unit/Payroll tests/Feature/Payroll
```

Pint and PHPStan run against owned module/models/factories/migration/tests. Parent owns canonical contract merge, cross-module final regression and browser E2E. No commit/push performed.
