# Solution Design Document

**Spec ID:** 050b-everee-pay-run-plumbing
**Scope:** Phase 1b (Pay Run Plumbing) — Backend + Web slice
**Companion docs:** [product-requirements.md](./product-requirements.md) (PRD, Codex-reviewed 2026-06-03) · [README.md](./README.md) · builds on [050 SDD](../050-everee-payroll-foundations/solution-design.md)
**Authored:** 2026-06-03

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** (ADR-1b…ADR-14b confirmed 2026-06-04 by rvanvuren)
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

### Language, Framework, and Stack
- CON-1 PHP 8.x, Slim 2.6.2, Twig 1.44.8, MySQL (multi-store), Redis, TaskEngine. Front end: Bootstrap 5.3.3 + Syncfusion EJ2 + design-token CSS. No build step for backend; admin CSS/JS shipped as static assets with manual `?v=` cache-busting (no asset pipeline for these pages).
- CON-2 All new backend code lands in the single namespace `BuyerKiosk\Payroll\` (ADR-8 from 050). No payroll code in `Core\` or `Employee\`. New admin web pages follow the established Slim-2-route → controller → self-contained Twig → IIFE page-JS → CSS-module recipe (see ICO-5).

### Coding Standards
- CON-3 camelCase DB columns; integer cents for money; `DECIMAL` for hours; **no FLOAT/DOUBLE** in any pay computation. Every `catch` uses `\Throwable` or a specific typed exception (never bare `catch (Exception)`), per the 050 memory gotcha.
- CON-4 Schema changes ONLY via `userfrosting/conductor` JSON migrations (CLAUDE.md). Central-DB (`kiosk_buykiosk`) migrations use op type `alter_table` (no `alter_column` case exists in `globalMigration.php`).

### Security and Compliance
- CON-5 Every payroll-mutating route is gated by: UF session → `checkAccess('<permission>')` → `checkStoreGroup($typeNum)` → CSRF (`\NoCSRF::check('csrf_token', …)`) → `SchedulingProviderGate::assertAllowed(...)`. Read endpoints drop CSRF + gate. (Mirrors `PayrollAdminController` Phase 1a.)
- CON-6 IRS retention: no hard-delete path on `payrollRuns`, `payrollRunLines`, `payrollRunSnapshots`, `payRateHistory`, `payrollAuditLog` (all FKs `ON DELETE RESTRICT`). Corrections are new rows/runs.
- CON-7 PII: SSN/bank/W-4/I-9 never enter BK (Phase 1a redaction holds on the webhook path). Onboarding kickoff uses embedded onboarding so the worker self-completes that PII.
- CON-8 **Sandbox only.** No production Everee credentials wired; the readiness gate (PRD F5) enforces it as a single seam.

### Data Modeling
- CON-9 `payrollRuns` / `payrollRunLines` / `payrollRunSnapshots` already exist (Phase 0 migration `20260522_002`). This slice POPULATES them and extends the status enums (ADR-1b). `payrollTenants.payFrequency`, `payPeriodEndsOnDayOfWeek`, `twoPersonApprovalThresholdCents` already exist and are CONSUMED here, not re-created.

### Partner and Operational
- CON-10 **Partner-gated:** the Everee pay-run submission API shape, the embedded-onboarding kickoff flow, and the five `payment.*`/`worker.new-tax-forms-available` webhook payloads were never captured live in Phase 1a. SDD *authoring* proceeds on the documented model; SDD *finalization* (and the submit/onboarding/webhook implementation) is gated on a live sandbox capture (PRD Open Questions). **The implementation plan MUST schedule this live sandbox capture as a phase-0 gate before the submit/onboarding/webhook tasks.** If pay-run submission is portal-only, ADR-5b's manual-submission seam applies.

---

## Implementation Context

**IMPORTANT**: Implementers MUST read all listed sources before coding.

### Required Context Sources

- ICO-1 Specs & foundations
 ```yaml
 - doc: docs/specs/050b-everee-pay-run-plumbing/product-requirements.md
   relevance: CRITICAL
   why: "Every component traces to a PRD feature/AC; the 'Resolved during PRD review' block is binding policy"
 - doc: docs/specs/050-everee-payroll-foundations/solution-design.md
   relevance: CRITICAL
   why: "The foundation service contracts, ADRs, error matrix, and test-spec format this SDD extends"
 - doc: docs/everee-payroll-integration-analysis.md
   relevance: HIGH
   sections: ["§6 punch-lock rules", "§7 Phase 1b scope"]
   why: "Locked decisions: punch lock at submission, weekly-OT, clean cut-over"
 ```
- ICO-2 Pay-run data + foundation services
 ```yaml
 - file: userfrosting/migrations/input/20260522_002_payroll_create_runs.json
   relevance: CRITICAL
   why: "payrollRuns/payrollRunLines/payrollRunSnapshots columns this slice populates + the status enums ADR-1b extends"
 - file: userfrosting/migrations/input/20260522_001_payroll_create_tenants.json
   relevance: HIGH
   why: "payFrequency, payPeriodEndsOnDayOfWeek, payCutoffHoursBefore, twoPersonApprovalThresholdCents already exist"
 - file: userfrosting/src/BuyerKiosk/Payroll/Services/PayRateService.php
   relevance: CRITICAL
   sections: ["getRate() Algorithm 1, line 176-213"]
   why: "Pay-run line rate resolution calls getRate(user, tenant, position, workDate)"
 - file: userfrosting/src/BuyerKiosk/Payroll/Services/EvereeApiClient.php
   relevance: CRITICAL
   sections: ["createWorker() embedded line 303", "_request auth/idempotency"]
   why: "Extend with submitPayRun()/getPayRun(); reuse the auth + retry + idempotency frame"
 - file: userfrosting/src/BuyerKiosk/Payroll/Jobs/ProcessEvereeWebhookJob.php
   relevance: CRITICAL
   sections: ["DEFERRED_PHASE_1B_EVENTS line 87-94", "event switch line 232-272"]
   why: "Implement the 5 deferred handlers; follow the worker.* handler shape"
 - file: userfrosting/src/BuyerKiosk/Payroll/Repositories/UserPayrollProfileRepository.php
   relevance: HIGH
   sections: ["apply* helpers returning rowCount, line 197-343"]
   why: "Onboarding kickoff + payment handlers write via the same rowCount-returning helper shape"
 - file: userfrosting/src/BuyerKiosk/Payroll/Controllers/PayrollAdminController.php
   relevance: CRITICAL
   why: "Extend with roster/onboarding/pay-run endpoints; reuse verifyCsrf + gate + strict-parse pattern"
 - file: userfrosting/routes/api/payroll.php
   relevance: HIGH
   why: "Add the new admin API routes alongside the existing provision/setRate/retire/as-of"
 ```
- ICO-3 Timesheet / overtime source
 ```yaml
 - file: userfrosting/src/BuyerKiosk/Scheduling/Services/OvertimeCalculator.php
   relevance: CRITICAL
   sections: ["calculateForWeek() line 131-247", "isExemptEmployee() line 266-284 reads users.isExempt"]
   why: "Overtime is computed WEEKLY and already honors users.isExempt; the pay-run CONSUMES approved timesheets, not raw OT re-derivation"
 - file: userfrosting/src/BuyerKiosk/Scheduling/Repositories/TimesheetRepository.php
   relevance: CRITICAL
   why: "findApprovedForWeek(): approved scheduleTimesheets carry regular/OT/DT hours — the run's hours source"
 - file: userfrosting/migrations/input/20251220_013_003_schedule_timesheets.json
   relevance: HIGH
   why: "scheduleTimesheets: weekStartDate/weekEndDate, regularHours/overtimeHours/doubletimeHours, status enum(pending,approved,exported), approvedByUserId"
 - file: userfrosting/src/BuyerKiosk/Scheduling/Repositories/TimePunchRepository.php
   relevance: MEDIUM
   sections: ["findByEmployeeAndDateRange() line 75", "calculateWorkedHours() line 468"]
   why: "Day-level punch allocation for semi-monthly periods that straddle workweeks (ADR-2b)"
 - file: userfrosting/migrations/input/20260522_013_schedule_time_punches_tips.json
   relevance: HIGH
   why: "scheduleTimePunches.submittedToEvereeAt = the lock signal this slice SETS at submit (CON-6/§6)"
 ```
- ICO-4 Salaried/exempt model
 ```yaml
 - file: userfrosting/migrations/input/20260414_004_users_isExempt.json
   relevance: HIGH
   why: "users.isExempt drives OvertimeCalculator; must stay consistent with employmentClassification (ADR-3b)"
 - file: userfrosting/migrations/input/20260522_011_user_store_assignments_classification.json
   relevance: HIGH
   why: "userStoreAssignments.employmentClassification enum(w2_hourly,w2_salaried) — BK-canonical, Everee-facing; read+set this slice"
 ```
- ICO-5 Admin web-page house pattern (clone target)
 ```yaml
 - file: userfrosting/routes/admin/quickbooks.php
   relevance: CRITICAL
   sections: ["group /admin/:typeNum/quickbooks", "$authorize closure line 31-60"]
   why: "Canonical admin route + auth-closure + controller-instantiation recipe to clone for 6 payroll pages"
 - file: userfrosting/src/BuyerKiosk/QuickBooks/Controllers/QBPageController.php
   relevance: CRITICAL
   sections: ["checkAccess() 50-68", "buildPageData() 77-90 (\\NoCSRF::generate)", "display*()"]
   why: "PayrollPageController clones this; NoCSRF single-use token to Twig"
 - file: userfrosting/templates/themes/default/quickbooks/dashboard.html
   relevance: HIGH
   why: "Self-contained Twig page shape (CSS links + nav include + IIFE init); clone for payroll pages"
 - file: public_html/js/quickbooks/dashboard.js
   relevance: HIGH
   why: "IIFE singleton + fetch + CSRF-in-body recipe for page JS"
 - file: userfrosting/templates/themes/default/menus/sidebar.html
   relevance: HIGH
   sections: ["permission-gated <li> blocks via checkAccess('uri_…')"]
   why: "Register the Payroll nav section (PRD F6), gated by a payroll permission + store.typeNum"
 ```

### Implementation Boundaries
- **Must Preserve:** the Phase 1a service contracts (PayRateService append-only, EvereeApiClient auth/idempotency frame, webhook ingestion idempotency + redaction + claim/dedupe, SchedulingProviderGate fail-closed, PayrollAuditService decoupled-snapshot contract). The OvertimeCalculator + TimesheetRepository scheduling contracts. CSRF + permission + store-group middleware order.
- **Can Modify:** `ProcessEvereeWebhookJob` (implement the 5 deferred handlers + shrink `DEFERRED_PHASE_1B_EVENTS`), `EvereeApiClient` (add submit/get pay-run methods), `PayrollAdminController` + `routes/api/payroll.php` (add endpoints), `UserPayrollProfileRepository` (add payment/tax helpers), `payrollRuns`/`payrollRunLines` status enums (additive migration).
- **Must Not Touch:** `Security\Encryption` (shared with QBO), the append-only `payRateHistory` write path + DB triggers, the deprecated store-level `employees` table (use `users`/`userStoreAssignments`), WIW/Homebase scheduling paths (gated out).

### External Interfaces

#### System Context Diagram
```mermaid
graph TB
    Owner[Store Owner / Manager] -->|admin web pages| BK[BuyerKiosk Web<br/>BuyerKiosk\\Payroll]
    CS[Customer Success] -->|same pages| BK
    BK -->|Basic + x-everee-tenant-id<br/>/api/v2| Everee[(Everee Sandbox API)]
    Everee -->|HMAC webhooks<br/>POST /api/payroll/webhook/everee| BK
    BK --> CentralDB[(kiosk_buykiosk<br/>payrollRuns/Lines/Snapshots,<br/>payRateHistory, payrollTenants,<br/>userPayrollProfiles, payrollAuditLog)]
    BK --> UsersDB[(kiosk_users<br/>users, userStoreAssignments)]
    BK --> StoreDB[(kiosk_<store><br/>scheduleTimesheets,<br/>scheduleTimePunches, schedulePositions)]
    BK --> TaskEngine[[TaskEngine<br/>ProcessEvereeWebhookJob]]
    Redis[(Redis)] --- TaskEngine
```

#### Interface Specifications
```yaml
inbound:
  - name: "Payroll admin web pages"
    type: HTTPS
    format: HTML (Twig) + JSON (XHR)
    authentication: UF session + checkAccess + checkStoreGroup + CSRF
    data_flow: "Owner/manager provision, onboard, set rates, build/preview/submit pay runs"
  - name: "Everee webhook receiver (existing)"
    type: HTTPS
    format: JSON
    authentication: HMAC-SHA256 (existing)
    data_flow: "payment.* + worker.new-tax-forms-available now drive run-line/profile state"
outbound:
  - name: "Everee REST API"
    type: HTTPS
    format: REST /api/v2
    authentication: per-tenant Basic + x-everee-tenant-id (existing client)
    data_flow: "createWorker (embedded onboarding) + NEW submitPayRun/getPayRun"
    criticality: HIGH
data:
  - name: "Central DB (kiosk_buykiosk)"
    type: MySQL
    data_flow: "payrollRuns/Lines/Snapshots, payRateHistory, payrollTenants, userPayrollProfiles, payrollAuditLog"
  - name: "Per-store DB (kiosk_<store>)"
    type: MySQL
    data_flow: "scheduleTimesheets (approved hours source), scheduleTimePunches (lock at submit), schedulePositions"
```

### Cross-Component Boundaries
- API contract that cannot break: `POST /api/payroll/webhook/everee` (Everee-facing). New admin endpoints are internal (BK web ↔ BK page JS).
- Shared resources: TaskEngine `default` queue + Redis (webhook processing). The pay-run *submit* path is synchronous in the request thread (an owner-initiated action with immediate feedback), NOT a TaskEngine job — submission is user-driven and must surface its result inline.

### Project Commands
```bash
# Component: BuyerKiosk Web (PHP / Slim 2)
Unit Tests:        ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
Targeted:          cd userfrosting && ./vendor/bin/phpunit --filter PayRunServiceTest
Static analysis:   cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Payroll/
Migrations:        php userfrosting/conductor run        # or buyerkiosk-conductor-targeted-migration skill for one store
Token-leak scan:   php userfrosting/bin/payroll/check-token-leaks.php
TaskEngine worker: php userfrosting/bin/task worker:start
# No CSS/JS build step for these admin pages; bump ?v= on changed assets.
```

---

## Solution Strategy

- **Architecture Pattern:** Layered modular monolith, extending the `BuyerKiosk\Payroll\` package that 050 established. New work splits cleanly into a **service layer** (pay-run lifecycle, onboarding, period generation, run computation), a **HTTP layer** (one admin page controller + extended API controller + routes), a **persistence layer** (3 new repositories over the existing run tables), and a **web layer** (6 pages cloning the QuickBooks admin recipe). The webhook + Everee-client extensions plug into existing Phase 1a seams.
- **Integration Approach:** Maximize reuse. The pay-run computation *consumes* approved `scheduleTimesheets` (which already ran `OvertimeCalculator`, honoring `users.isExempt`) for hours, and `PayRateService::getRate` for rates — it does NOT re-derive overtime. Everee writes reuse the existing `EvereeApiClient` auth/idempotency frame (extended with `submitPayRun`/`getPayRun`). Webhook handling extends the existing job. Audit/permissions/gate/CSRF are the Phase 1a middleware, unchanged.
- **Justification:** The single largest correctness risk is the gross-pay computation (real money). Consuming pre-approved, pre-overtime-computed timesheets — rather than recomputing — keeps one source of truth for hours (the same numbers the manager already approved on screen) and confines this slice's new math to "hours × rate (+ multipliers) + bonuses in integer cents." Reuse of the Phase 1a frames keeps the security-critical surface (tokens, HMAC, gate, audit) untouched and already-reviewed.
- **Key Decisions:** see ADR-1b…ADR-10b. The load-bearing ones: consume approved timesheets with weekly-OT allocation (ADR-2b); BK-side approval state machine via an additive enum migration (ADR-1b, ADR-4b); idempotent submit with a portal-only fallback seam (ADR-5b); pre-submit snapshot + punch lock for immutability (ADR-6b).

---

## Building Block View

### Components
```mermaid
graph LR
    subgraph Web
      Pages[6 Twig pages + IIFE JS]
    end
    subgraph HTTP
      PageCtl[PayrollPageController]
      ApiCtl[PayrollAdminController +ext]
      Routes[routes/admin/payroll.php +<br/>routes/api/payroll.php +ext]
    end
    subgraph Services
      OnbSvc[EvereeOnboardingService]
      RunSvc[PayRunService]
      PeriodCalc[PayPeriodCalculator]
      RunCalc[PayRunCalculator]
      Gate[SchedulingProviderGate*]
      Audit[PayrollAuditService*]
      Rate[PayRateService*]
      ApiClient[EvereeApiClient +submitPayRun]
      Readiness[PayrollReadinessGate]
    end
    subgraph Persistence
      RunRepo[PayRunRepository]
      LineRepo[PayRunLineRepository]
      SnapRepo[PayRunSnapshotRepository]
      ProfileRepo[UserPayrollProfileRepository* +ext]
      TenantRepo[PayrollTenantRepository*]
      TsRepo[TimesheetRepository*]
    end
    Job[ProcessEvereeWebhookJob +5 handlers]

    Pages --> PageCtl
    Pages --> ApiCtl
    Routes --> PageCtl
    Routes --> ApiCtl
    ApiCtl --> OnbSvc
    ApiCtl --> RunSvc
    OnbSvc --> ApiClient
    OnbSvc --> ProfileRepo
    RunSvc --> PeriodCalc
    RunSvc --> RunCalc
    RunSvc --> RunRepo
    RunSvc --> LineRepo
    RunSvc --> SnapRepo
    RunSvc --> ApiClient
    RunSvc --> Readiness
    RunCalc --> TsRepo
    RunCalc --> Rate
    ApiCtl --> Gate
    RunSvc --> Audit
    Job --> LineRepo
    Job --> ProfileRepo
    Job --> Audit
    classDef ext fill:#eef;
```
`*` = existing Phase 1a component (reused, lightly extended where marked).

### Directory Map
```
userfrosting/
├── src/BuyerKiosk/Payroll/
│   ├── Services/
│   │   ├── EvereeOnboardingService.php        # NEW  (Feature 1)
│   │   ├── PayRunService.php                  # NEW  (Feature 2 lifecycle/state machine)
│   │   ├── PayPeriodCalculator.php            # NEW  (period generation from payFrequency, ADR-8b)
│   │   ├── PayRunCalculator.php               # NEW  (timesheet+rate → lines, ADR-2b)
│   │   ├── PayrollReadinessGate.php           # NEW  (sandbox-only gate, Feature 5 / ADR-11b / CON-8)
│   │   ├── EmploymentClassificationService.php# NEW  (classification↔isExempt tenant-wide consistency, ADR-3b)
│   │   ├── TenantStoreResolver.php            # NEW  (BK-native stores for tenant + full-access check, ADR-10b)
│   │   └── EvereeApiClient.php                # MODIFY (+createPayablesBulk/+createPayablePaymentRequest/+getPayable/+deletePayable — Payables API, ADR-5b)
│   ├── Repositories/
│   │   ├── PayRunRepository.php               # NEW  (serializing-lock create: advisory GET_LOCK / parent-row, ADR-14b)
│   │   ├── PayRunLineRepository.php           # NEW  (+typeNum/positionId/rateType/rateCents grain, ADR-13b)
│   │   ├── PayRunSnapshotRepository.php       # NEW
│   │   ├── PayRunExclusionRepository.php      # NEW  (durable exclusions, ADR-12b)
│   │   └── UserPayrollProfileRepository.php   # MODIFY (+payment/tax mirror helpers)
│   ├── Models/
│   │   ├── PayRun.php                         # NEW
│   │   ├── PayRunLine.php                     # NEW
│   │   ├── PayPeriod.php                      # NEW (value object)
│   │   └── PayRunPreview.php                  # NEW (value object: lines + totals + blockers)
│   ├── Controllers/
│   │   ├── PayrollPageController.php          # NEW (6 display* methods)
│   │   └── PayrollAdminController.php         # MODIFY (+roster/onboarding/run endpoints)
│   ├── Jobs/ProcessEvereeWebhookJob.php       # MODIFY (5 deferred handlers)
│   └── Exceptions/
│       ├── PayRunStateException.php           # NEW
│       ├── PayRunNotReadyException.php        # NEW (blockers present)
│       └── PayrollReadinessException.php      # NEW (non-sandbox)
├── routes/
│   ├── admin/payroll.php                      # NEW (6 page GET routes)
│   └── api/payroll.php                        # MODIFY (+ roster/onboarding/run JSON endpoints)
├── migrations/input/                          # NEW (see Data Storage Changes for full DDL)
│   ├── 20260603_001_payroll_run_status_enums.json        # ADR-1b append-only enum extend (central)
│   ├── 20260603_002_payroll_run_lines_position.json      # ADR-13b positionId/rateType/rateCents (central)
│   ├── 20260603_003_payroll_tenants_period_env.json      # ADR-11b environment + ADR-8b anchor/boundary (central)
│   ├── 20260603_004_user_payroll_profiles_mirrors.json   # F4 payment/tax mirror cols (kiosk_users)
│   ├── 20260603_005_payroll_run_exclusions.json          # ADR-12b exclusions table (central)
│   └── 20260603_006_schedule_time_punches_run_lock.json  # ADR-6b submittedToEvereeRunId (PER-STORE)
├── templates/themes/default/
│   ├── payroll/                               # NEW: index.html, setup.html, employees.html,
│   │                                          #      rates.html, runs.html, run-detail.html
│   └── menus/sidebar.html                     # MODIFY (+Payroll nav section, F6)
public_html/
├── js/payroll/                                # NEW: index.js, setup.js, employees.js, rates.js,
│   │                                          #      runs.js, run-detail.js  (IIFE singletons)
└── css/admin/modules/                         # NEW: payroll-*.css  (one per page, scoped)
tests/
├── Unit/Payroll/{Services,Repositories,Jobs,Controllers}/   # NEW tests per class
├── Integration/Payroll/                       # NEW: end-to-end run + webhook integration
└── Fixtures/Payroll/                          # NEW: everee-payrun-*.json, everee-webhook-payment-*.json
```

### Interface Specifications

#### Data Storage Changes

Six migrations (central + per-store). Central-DB migrations use op type `alter_table`. **Enum extension APPENDS values at the end** (MySQL stores ENUM by ordinal; appending does not rewrite/shift existing ordinals — inserting mid-list would, ADR-1b). PRD "error" maps to the canonical terminal-error state **`failed`** (the schema's existing value); `error` is not a separate state.

```yaml
# (1) 20260603_001_payroll_run_status_enums.json — central, alter_table, APPEND-only
Table: payrollRuns
  MODIFY status:
    ENUM('draft','submitted','funded','paid','failed','cancelled')                # existing
    -> ENUM('draft','submitted','funded','paid','failed','cancelled','pending_approval','partial_error','needs_reconciliation')
    # needs_reconciliation = uncertain-submit recovery state (locks held; ADR-5b/6b); surfaced in the runs grid
Table: payrollRunLines
  MODIFY status:
    ENUM('staged','submitted','funded','paid','failed')                           # existing
    -> ENUM('staged','submitted','funded','paid','failed','returned')             # +returned (deposit-returned)

# (2) 20260603_002_payroll_run_lines_position.json — central, alter_table
Table: payrollRunLines
  ADD COLUMN typeNum    VARCHAR(8)  NULL AFTER userId         # store the hours came from — qualifies positionId (ADR-13b)
  ADD COLUMN positionId INT UNSIGNED NULL AFTER typeNum       # PER-STORE local id; interpreted within typeNum
  ADD COLUMN rateType   ENUM('hourly','salary_annual') NULL   # the rate type used for the line (audit)
  ADD COLUMN rateCents  INT NULL                              # the resolved rate used (audit; cents)
  ADD INDEX idx_run_user_store_position (payrollRunId, userId, typeNum, positionId)

# (3) 20260603_003_payroll_tenants_period_env.json — central, alter_table
Table: payrollTenants
  ADD COLUMN environment ENUM('sandbox','production') NOT NULL DEFAULT 'sandbox'   # ADR-11b — the REAL readiness signal (F5)
  ADD COLUMN payPeriodAnchorDate DATE NULL                    # ADR-8b — biweekly anchor (which of the two weeks); required for biweekly
  ADD COLUMN semiMonthlySecondPeriodStartDay TINYINT UNSIGNED NULL DEFAULT 16      # ADR-8b — semi-monthly boundary (1..(day-1)) / (day..EOM)

# (4) 20260603_004_user_payroll_profiles_mirrors.json — central (kiosk_users), alter_table
Table: userPayrollProfiles
  ADD COLUMN paymentMethodUpdatedAt DATETIME NULL             # F4 payment.updated-payment-method mirror (no PII)
  ADD COLUMN taxFormsAvailableAt    DATETIME NULL             # F4 worker.new-tax-forms-available mirror (no PII)

# (5) 20260603_005_payroll_run_exclusions.json — central, create_table (ADR-12b — DURABLE exclusion)
Table: payrollRunExclusions (NEW)
  id INT UNSIGNED PK AUTO_INCREMENT
  payrollRunId INT UNSIGNED NOT NULL  FK -> payrollRuns(id) ON DELETE RESTRICT
  userId INT UNSIGNED NOT NULL
  reason TEXT NOT NULL
  excludedByUserId INT UNSIGNED NOT NULL
  excludedAt DATETIME NOT NULL
  UNIQUE idx_run_user (payrollRunId, userId)                 # one exclusion per worker per run

# (6) 20260603_006_schedule_time_punches_run_lock.json — PER-STORE ({{store}}), alter_table
Table: scheduleTimePunches
  ADD COLUMN submittedToEvereeRunId INT UNSIGNED NULL          # ADR-6b — set at the per-store LOCK step (BEFORE submit); run-attributable so a failed/uncertain submit can release/reconcile it
  # scheduleTimePunches.submittedToEvereeAt already exists (Phase 1a) — both set together at lock time

# CONSUMED (no change): payrollTenants.payFrequency/payPeriodEndsOnDayOfWeek/payCutoffHoursBefore/
#   twoPersonApprovalThresholdCents; userStoreAssignments.employmentClassification; users.isExempt;
#   run cols evereePayRunId/period*/submitted*/approved*/total*/employeeCount; line cols evereeWorkerId/
#   regular|overtime|doubletime|ptoHours/bonusAmountCents/grossWagesCents/netWagesCents/evereePayableId/
#   lineSnapshotJson; snapshot snapshotType/sourceDataJson.
# Draft idempotency on (tenant, period): a serializing lock FIRST (advisory GET_LOCK('payrun:<tenant>:<period>')
#   or parent payrollTenants row FOR UPDATE), then check-or-insert over non-cancelled runs — NOT a hard
#   UNIQUE index (a cancelled run must allow re-creation), ADR-14b.
```

#### Internal API Changes (extend `routes/api/payroll.php` + `PayrollAdminController`)
```yaml
# Roster + onboarding (Features 8, 1)
Endpoint: List roster with payroll status
  Method: GET   Path: /api/payroll/admin/:typeNum/roster
  Auth: view_pay_run + checkStoreGroup
  Response: { workers: [{ userId, name, positionId, employmentClassification,
              onboardingStatus, tinStatus, readyToPay: bool, rateSet: bool, blockReason? }] }
Endpoint: Kick off onboarding
  Method: POST  Path: /api/payroll/admin/:typeNum/onboarding/kickoff
  Auth: kickoff_employee_onboarding + checkStoreGroup + CSRF + gate + readiness
  Request: { userId, typicalWeeklyHours }   # 1..40
  # PRECONDITION (enforced; the embedded createWorker contract requires payType+payRate, so onboarding
  #   CANNOT precede classification+rate): the worker MUST already have (a) employmentClassification set,
  #   and (b) a current rate resolvable via PayRateService.getRate(today). The service DERIVES the Everee
  #   payType from rateType (hourly->HOURLY, salary_annual->SALARY) and payRate from the resolved rateCents;
  #   legal name/DOB/address come from the BK write-once-prefill fields; SSN/bank stay with the worker
  #   (embedded). If classification or rate is missing -> 422 { error: onboarding_precondition_unmet, missing:[...] }.
  Response: 201 { userId, evereeWorkerId, onboardingStatus, idempotentReturn: bool }
  # Roster ordering (Features 8/9/3): set classification + rate FIRST, then kick off onboarding.

# Classification (Feature 3)
Endpoint: Set employment classification
  Method: POST  Path: /api/payroll/admin/:typeNum/classification
  Auth: set_pay_rate + checkStoreGroup + CSRF + gate
  Request: { userId, employmentClassification: w2_hourly|w2_salaried }
  Response: 200 { userId, employmentClassification, isExempt }   # ADR-3b keeps users.isExempt consistent

# Pay-period options (Feature 2/ADR-8b)
Endpoint: List selectable pay periods
  Method: GET   Path: /api/payroll/admin/:typeNum/pay-periods
  Auth: view_pay_run + checkStoreGroup
  Response: { payFrequency, periods: [{ periodStart, periodEnd, label, hasRun: bool, runId? }] }

# Pay-run lifecycle (Features 2, 10, 11)
Endpoint: Create/ensure draft run (idempotent on tenant+period)
  Method: POST  Path: /api/payroll/admin/:typeNum/runs
  Auth: submit_pay_run + checkStoreGroup + CSRF + gate + readiness
  Request: { periodStart, periodEnd }
  Response: 201|200 { runId, status, idempotentReturn }
Endpoint: List runs (grid)
  Method: GET   Path: /api/payroll/admin/:typeNum/runs
  Auth: view_pay_run + checkStoreGroup
  Response: { runs: [{ runId, periodStart, periodEnd, status, totalGrossCents, employeeCount, submittedAt }] }
Endpoint: Run detail + dry-run preview (recomputes fresh; marks stale never served)
  Method: GET   Path: /api/payroll/admin/:typeNum/runs/:runId
  Auth: view_pay_run + checkStoreGroup
  Response: { run:{…}, lines:[{ userId, name, regularHours, overtimeHours, doubletimeHours,
              ptoHours, rateCents, rateType, grossWagesCents, paymentStatus?, blockReason? }],
              totals:{ grossCents, employeeCount }, blockers:[…], canSubmit: bool, stale: bool }
Endpoint: Exclude a not-ready worker (audited)
  Method: POST  Path: /api/payroll/admin/:typeNum/runs/:runId/exclude
  Auth: submit_pay_run + checkStoreGroup + CSRF + gate
  Request: { userId, reason }
  Response: 200 { runId, excludedUserId }
Endpoint: Submit for approval (submit_pay_run, lacks approve)  → pending_approval
Endpoint: Approve + submit to Everee (approve_pay_run / above-threshold)
  Method: POST  Path: /api/payroll/admin/:typeNum/runs/:runId/submit
  Auth: submit_pay_run (and approve_pay_run / approve_pay_run_above_threshold per state machine)
        + checkStoreGroup + CSRF + gate + readiness
  Request: { idempotencyKey? }   # server derives a stable key from runId if absent
  Response: 200 { runId, status: pending_approval|submitted, evereePayRunId? }
Endpoint: Cancel run (only while Everee permits)
  Method: POST  Path: /api/payroll/admin/:typeNum/runs/:runId/cancel
  Auth: submit_pay_run + checkStoreGroup + CSRF + gate + readiness   # Everee-mutating -> readiness too
  Response: 200 { runId, status: cancelled } | 409 { error: cancel_not_permitted }
```

#### Application Data Models
```pseudocode
ENTITY: PayRun (NEW; over existing payrollRuns)
  FIELDS: id, payrollTenantId, evereePayRunId?, periodStart, periodEnd,
          status(draft|pending_approval|submitted|funded|paid|partial_error|failed|cancelled|needs_reconciliation),
          submittedByUserId?, submittedAt?, approvedByUserId?, approvedAt?,
          totalGrossCents?, totalNetCents?, total*TaxCents?, employeeCount?
  BEHAVIORS: isTerminal(), canTransitionTo(status), isCancellable()
  # needs_reconciliation: an uncertain submit kept punch locks but the Everee outcome is not yet
  #   authoritatively known; the run is visible/audited in this state until a backoff recovery resolves it.

ENTITY: PayRunLine (NEW; over existing payrollRunLines)  # grain = (userId, typeNum, positionId, rate-segment)
  FIELDS: id, payrollRunId, userId, typeNum, positionId, evereeWorkerId?, rateType, rateCents,
          regularHours, overtimeHours, doubletimeHours, ptoHours, bonusAmountCents,
          grossWagesCents, netWagesCents?, evereePayableId?,
          status(staged|submitted|funded|paid|returned|failed), lineSnapshotJson?
  BEHAVIORS: computeGrossCents(): int   # integer-only (ADR-13b / Example 1); a (user,position) pair
                                        # splits into multiple lines when a rate change falls mid-period
ENTITY: PayRunExclusion (NEW; payrollRunExclusions)
  FIELDS: id, payrollRunId, userId, reason, excludedByUserId, excludedAt
  # durable: preview + submit-payload construction READ this; excluded workers' hours are carried
  # (their punches are NOT locked), so they roll into a future run.

VALUE: PayPeriod (NEW)  { periodStart: Date, periodEnd: Date, label: string, hasRun: bool }
VALUE: PayRunPreview (NEW){ lines: PayRunLine[], totals, blockers: Blocker[], excluded: [{userId,reason}],
                            canSubmit: bool, stale: bool }
VALUE: Blocker (NEW)     { userId, typeNum?, positionId?, kind(missing_rate|not_ready|
                           classification_mismatch|multi_rate_overtime|position_ambiguous|
                           timesheet_reconciliation), message }
  # multi_rate_overtime (ADR-2b/13b): ANY workweek whose OT-incurring hours span >1 distinct rate —
  #   multiple positions OR a single position's mid-week rate change — is BLOCKED in 050b (FLSA
  #   blended-rate deferred); a single-rate week computes cleanly.
  # position_ambiguous (ADR-13b): same (userId, positionId) from two different stores in one tenant-wide
  #   run -> the (userId, tenant, positionId) rate key is ambiguous -> BLOCKED (rare; tech debt).
  # timesheet_reconciliation (ADR-2b): punch-derived weekly hours do not reconcile to the approved
  #   timesheet totals -> BLOCKED (the approved totals are authoritative).

MODIFIED: UserPayrollProfile  (+ paymentMethodUpdatedAt?, taxFormsAvailableAt? mirror flags, no PII)
MODIFIED: users.isExempt      (kept consistent with userStoreAssignments.employmentClassification,
                               enforced tenant-wide by EmploymentClassificationService; ADR-3b)
```

#### Integration Points
```yaml
External_Service_Everee:
  - createWorker (embedded) : onboarding kickoff (existing client method)
  - submitPayRun / getPayRun: NEW client methods (partner-gated shape, CON-10)
  - webhooks: payment.paid|deposit-returned|updated-payment-method, payment-payables.status-changed,
              worker.new-tax-forms-available  → run-line/profile updates (was no-op)
Internal:
  - Scheduling: TimesheetRepository.findApprovedFor(period) — approved hours source (read-only)
  - Scheduling: set scheduleTimePunches.submittedToEvereeAt on submit (the lock; write to per-store DB)
  - Auth: userStoreAssignments (roster, classification, per-store access check for tenant-wide runs)
```

### Implementation Examples

#### Example 1: Integer-only gross computation + salaried proration (the load-bearing math)
**Why:** the single highest-stakes calculation; must be integer-cents (NO PHP float in the money path) and consume pre-approved OT.
```php
// PayRunCalculator — integer-only. Hours DECIMAL(8,4) -> integer ten-thousandths
// (hoursTTH = (int) round(hours * 10000)). Rate in cents. OT/DT multipliers as integer ratios
// (1.5 -> 15/10, 2.0 -> 20/10). Round half-up at the cent on each component.
public function computeHourlyLineGrossCents(
    int $rateCents, int $regTTH, int $otTTH, int $dtTTH,
    int $otMulNum, int $otMulDen, int $dtMulNum, int $dtMulDen, int $bonusCents
): int {
    $regular = intdiv($regTTH * $rateCents + 5000, 10000);                       // /10000, round half-up
    $ot      = intdiv($otTTH * $rateCents * $otMulNum + 5000 * $otMulDen, 10000 * $otMulDen);
    $dt      = intdiv($dtTTH * $rateCents * $dtMulNum + 5000 * $dtMulDen, 10000 * $dtMulDen);
    return $regular + $ot + $dt + $bonusCents;                                   // all int cents
}
// Salaried-exempt PERIOD gross — divide over the ACTUAL pay-period count in the period's PAYROLL YEAR
//   (52 or 53 weekly, 26 or 27 biweekly, else 24/12) so 53rd/27th-period years still sum exactly:
//   N   = count of generated periods whose periodEnd falls in that payroll year (calendar year of periodEnd)
//   base= intdiv(annualCents, N);  rem = annualCents % N
//   periods 1..N ordered by periodEnd: the first `rem` get base+1, the rest get base.  sum == annualCents.
//   exempt -> NO overtime. 050b pays FULL-period salary (mid-period hire/term proration is deferred).
```

#### Example 2: Period generation (anchored) + per-position sourcing + weekly-OT (ADR-2b, ADR-8b, ADR-13b)
**Why:** biweekly needs an anchor; semi-monthly straddles workweeks; rate resolution is per (position, work-date).
```
ALGORITHM: GeneratePeriods(tenant)            # PayPeriodCalculator (ADR-8b)
  weekly:       one-week periods ending on payPeriodEndsOnDayOfWeek
  biweekly:     14-day periods stepping from payPeriodAnchorDate (REQUIRED; the anchor disambiguates which week)
  semi_monthly: [day 1 .. (semiMonthlySecondPeriodStartDay-1)] and [day .. end-of-month]   # default split day 16
  monthly:      [day 1 .. end-of-month]
  -> owner SELECTS a generated period (PRD: no arbitrary dates)

ALGORITHM: BuildRunLines(tenant, period)      # PayRunCalculator (grain = user × store × position × rate-segment)
  stores = TenantStoreResolver.bkNativeStoresFor(tenant)         # WIW/Homebase excluded; full-tenant set
  excluded = PayRunExclusionRepository.forRun(run)               # durable exclusions (ADR-12b)
  FOR each worker (with approved hours in `period`, not excluded):
    FOR each approved WEEK overlapping `period` (per store):
      weeklyTotals = scheduleTimesheets(approved).{regular,ot,dt}        # AUTHORITATIVE manager-approved totals
      punchHrs[store][pos][day] from scheduleTimePunches.shiftId -> scheduleShifts -> schedulePositions
      ASSERT sum(punchHrs this week) reconciles to weeklyTotals          # else Blocker(timesheet_reconciliation)
      ALLOCATE weeklyTotals.regular across (store,pos,day) by punchHrs proportion, in integer
        ten-thousandths-of-an-hour with LARGEST-REMAINDER distribution so allocations sum EXACTLY to
        weeklyTotals.regular (no drift); a week straddling the period boundary allocates REGULAR by
        punch-DAY across the two periods.
      weeklyTotals.ot/dt -> the period containing this week's weekEndDate.
    FOR each (store, positionId, rate-segment):
      rate = PayRateService.getRate(worker, tenant, positionId, workDate)  # null -> Blocker(missing_rate)
      collision guard: same (userId, positionId) from two stores -> Blocker(position_ambiguous)
    IF the worker's OT-incurring hours in a week span >1 distinct rate -> Blocker(multi_rate_overtime)  # deferred
    ELSE attach OT/DT at that single rate
    emit PayRunLine per (worker, store, positionId, rate-segment); gross via Example 1
  RETURN preview(lines, totals, blockers, excluded)
```

#### Example 3: Two-step Payables submit — externalId idempotency + authoritative GET probe (ADR-5b)
**Why:** the real Everee **Payables API** (no `Idempotency-Key` header; atomic bulk create; per-payable `externalId` is the dedup key; `GET /payables/{externalId}` is the authoritative recovery probe). See `docs/interfaces/everee-payables-api.md`.
```php
// Two-step Payables submit. Idempotency = deterministic per-payable externalId (NO Idempotency-Key header).
$payables = array_map(fn($line) => [
    'externalId'       => "bk-payrun-{$run->getId()}-line-{$line->getId()}",  // deterministic -> re-POST safe
    'type'             => 'BK_PAYRUN',
    'label'            => 'BuyerKiosk pay run',
    'amount'           => ['amount' => $line->getGrossDollars(), 'currency' => 'USD'],  // WE supply the amount
    'payCode'          => $line->getPayCode(),
    'payableModel'     => 'PRE_CALCULATED',
    'timestamp'        => $run->getPeriodEndUnix(),
    'externalWorkerId' => $line->getEvereeWorkerRef(),
], $run->getLines());
try {
    $this->api->createPayablesBulk($tenant, $payables);                  // ATOMIC; re-POST idempotent by externalId
    $req = $this->api->createPayablePaymentRequest($tenant, [
        'externalIds' => array_column($payables, 'externalId'),
        'includeWorkersOnRegularPayCycle' => true,
    ]);
    $run->setEvereePayRunId((string) $req['id']);                        // PayablePaymentRequestDTO.id
} catch (EvereeUncertainStateException $e) {
    $probe = $this->api->getPayable($tenant, $payables[0]['externalId']); // authoritative, strongly consistent
    if ($probe !== null && $probe['payablePaymentRequestId'] !== null) { /* landed -> promote to submitted */ }
    elseif ($probe === null) { /* authoritative not-landed -> release locks, run stays draft/pending */ }
    else { throw $e; }                                                   // still uncertain -> needs_reconciliation (locks held)
}
// Everee then PAUSES the payment for its own admin-portal approval before money moves (ADR-4b): submitted != paid.
```

---

## Runtime View

### Primary Flow: Owner builds, previews, and submits a pay run
```mermaid
sequenceDiagram
    actor Owner
    participant JS as runs/run-detail JS
    participant Api as PayrollAdminController
    participant Svc as PayRunService
    participant Calc as PayRunCalculator
    participant Ts as TimesheetRepository
    participant Rate as PayRateService
    participant Ev as EvereeApiClient
    Owner->>JS: pick period, Create run
    JS->>Api: POST /runs {periodStart,periodEnd} (CSRF)
    Api->>Svc: createDraft(tenant, period, actor)  # gate+readiness+full-tenant-access
    Svc->>Calc: buildLines(period)
    Calc->>Ts: findApprovedWeeksOverlapping(period)
    Calc->>Rate: getRate(worker,tenant,position,workDate)
    Calc-->>Svc: preview(lines, blockers)
    Svc-->>Api: {runId, status:draft}
    Owner->>JS: open run-detail (dry-run preview)
    JS->>Api: GET /runs/:id  -> fresh preview (stale-safe)
    Owner->>JS: resolve blockers, Submit
    JS->>Api: POST /runs/:id/submit (CSRF)
    Api->>Svc: submit(run, actor)
    alt actor lacks approve_pay_run
        Svc-->>Api: status: pending_approval
    else actor approves (and above-threshold ok)
        Svc->>Svc: snapshot(pre_submit) + lock punches (submittedToEvereeAt)
        Svc->>Ev: submitPayRun(tenant, payload, idemKey)
        Svc-->>Api: status: submitted (evereePayRunId)
    end
    Note over Ev,Api: later: payment.* webhooks → line.status funded/paid/returned
```

### Error Handling
- **Validation (missing rate / classification mismatch / not-ready worker):** typed `PayRunNotReadyException` carrying the `Blocker[]`; HTTP 422 with the enumerated blockers; run stays draft. No partial state.
- **Permission / gate / readiness:** 403 (`forbidden` / `scheduling_provider_blocked` / `readiness_blocked`) + audit; never proceeds.
- **Tenant-wide access incomplete:** 403 naming the blocking store(s); no run created (ADR-10b — no partial run).
- **Everee submit auth (401/403):** `EvereeAuthException` → 502 with "re-provision/alert"; run stays submittable.
- **Everee submit timeout (uncertain):** `EvereeUncertainStateException` → verify-then-retry (Example 3); if still non-authoritative, the run stays `needs_reconciliation` with punch locks HELD (never released on a non-authoritative negative, never marked submitted on uncertainty); 502 surfaced to the user.
- **Everee partial rejection:** run → `partial_error`; accepted lines proceed, rejected lines flagged with reason; nothing silently `paid`.
- **Webhook line matching (ADR-9b — three distinct cases, no retry storms):** (a) NO line matches the event's `evereePayableId`/`evereeWorkerId` → audit `unmatched` + `markProcessed` (acknowledged, NO retry); (b) a line matches but the status UPDATE returns zero rows because the status is already applied → idempotent no-op + `markProcessed` (NOT failed); (c) a genuine DB error on a matched line → `markFailed` → retry. Only (c) retries.
- **Cross-DB submit recovery:** see Complex Logic RECOVERY — the lock PRECEDES submit (no editable window after Everee accepts); a definitive submit failure releases the run's locks; an uncertain submit keeps them and reconciles via `getPayRun`; a crash mid-sequence is repaired by run-attributed (`submittedToEvereeRunId`) reconciliation.
- **Run aggregate status roll-up:** when line statuses change (via `payment.*`), the run rolls up: ALL lines `paid` → run `paid`; ALL `funded` (none later) → `funded`; ANY `returned`/`failed` with others `paid`/`funded` → `partial_error`; ALL `failed` → `failed`. The roll-up is recomputed on each line-status change and audited.
- **Cancel after payment exists:** 409 `cancel_not_permitted`.
- All catches use `\Throwable`/typed exceptions; every mutation audits success AND failure.

### Complex Logic
```
ALGORITHM: PayRunService.submit(run, actor)
INPUT: run(draft|pending_approval), actor
1. ASSERT readiness gate (environment=sandbox AND sandbox base-URL) + scheduling-provider gate + full-tenant access
2. RECOMPUTE preview fresh (never trust a stored/stale preview)
3. IF blockers non-empty -> throw PayRunNotReadyException(blockers)
4. IF actor !has approve_pay_run:
     status=pending_approval ; record submittedByUserId/submittedAt ; AUDIT ; RETURN
   ELSE (approver):
     IF tenant.twoPersonApprovalThresholdCents set AND run.total > it
        AND actor !has approve_pay_run_above_threshold -> throw forbidden
5. WRITE payrollRunSnapshots(pre_submit) + per-line lineSnapshotJson (rate+hours frozen)        [central]
6. LOCK punches: set submittedToEvereeAt + submittedToEvereeRunId=run.id for included punches    [per-store]
   # lock BEFORE submit, so there is NO window where Everee has accepted payroll while punches stay editable (§6)
7. set status=needs_reconciliation                                                               [central]
   # DURABLE "submit-attempt-starting" marker, written BEFORE the network call. This is the recovery
   # predicate: status still draft/pending => submit NEVER started; status=needs_reconciliation => submit MAY have been sent.
8. SUBMIT to Everee idempotently (Example 3):                                                    [network]
     success            -> status=submitted + evereePayRunId + approvedBy/At                      [central]
     definitive fail     -> RELEASE lock WHERE submittedToEvereeRunId=run.id ; status back to draft/pending
     (auth/validation)      [per-store + central]
     uncertain (timeout) -> getPayRun(key): submitted -> success branch ;
                            AUTHORITATIVELY not-submitted (CON-10: partner-confirmed strongly-consistent lookup
                              post-timeout) -> release branch ;
                            else (lookup not yet authoritative) -> STAY needs_reconciliation (locks kept,
                              audited, surfaced) ; a backoff recovery re-verifies. NEVER release on a
                              non-authoritative negative.
9. AUDIT outcome ; RETURN   (payment.* webhooks later drive funded/paid/returned/partial_error)

RECOVERY (idempotent; "editable-after-accept" CANNOT occur — lock precedes submit; releases are PROOF-gated):
  - run LOCKED + status in {draft, pending_approval} (the step-7 marker never landed) -> the submit was
      NEVER started -> safe to RELEASE the locks.
  - run LOCKED + status = needs_reconciliation (a submit MAY have been sent) -> getPayRun(key):
      submitted -> promote to submitted ; AUTHORITATIVELY not-submitted -> release ; else KEEP locked +
      stay needs_reconciliation.
  - NO reconciliation path releases locks unless non-submission is AUTHORITATIVE or no submit was durably started.
```

---

## Deployment View

### Single Application Deployment
- **Environment:** local dev machine served via ngrok → `dev2.buyerkiosk.com` (no remote server; code is live on save). Production cut-over is MVP-Launch-Ready scope, not this slice.
- **Configuration:** reuses the six Phase 1a `EVEREE_*` env vars. **No new env var** (sandbox base URL + per-tenant tokens already present). The readiness gate keys off tenant sandbox state, not a new flag.
- **Dependencies:** Everee sandbox; Redis + TaskEngine worker for webhook processing (already running for Phase 1a).
- **Migration sequencing:** apply `20260603_001_payroll_run_status_enums.json` (additive enum) before deploying `PayRunService` (which writes the new states). Idempotent; safe to re-run.
- **Asset cache-busting:** bump `?v=` on each new/changed payroll JS/CSS (no pipeline).

### Multi-Component Coordination
No change — single PHP/Slim monolith. The sibling `050c` mobile slice consumes the same JSON endpoints later; this slice's API contract is forward-compatible (additive).

---

## Cross-Cutting Concepts

### Pattern Documentation
```yaml
- pattern: docs/patterns/payroll-token-encryption.md ; relevance: HIGH ; why: "tokens stay encrypted; submit reuses the frame"
- pattern: docs/patterns/taskengine-payroll-audit.md ; relevance: HIGH ; why: "payment.* handlers are TaskEngine work"
- pattern: csrf-token-refresh-spa-pattern (skill)   ; relevance: MEDIUM; why: "long-lived run-detail page may outlive a single-use token"
- pattern: syncfusion-grid-hidden-tab-raf-defer (skill); relevance: MEDIUM; why: "runs EJ2 grid render in tabs"
- pattern: bootstrap5-modal-backdrop-stacking (skill); relevance: HIGH ; why: "every payroll modal needs the body-relocation fix"
- pattern: docs/patterns/payroll-pay-run-lifecycle.md (NEW); relevance: HIGH; why: "documents the state machine + weekly-OT allocation (deliverable)"
```

### System-Wide Patterns
- **Security:** permission → store-group → CSRF → scheduling-provider gate → readiness gate, on every mutation; tokens never logged (token-leak scanner extended to new files); PII never stored.
- **Error Handling:** typed exceptions classified in services, translated to JSON at the route boundary; `\Throwable` everywhere; audit on success and failure.
- **Performance:** preview recomputation is read-only over approved timesheets + indexed rate lookups (`idx_userid_tenant_position_effective`); a run touches O(workers × weeks) rows — bounded by a store's roster (tens), not a hot path.
- **Logging/Auditing:** extend the Phase 1a `payrollAuditLog` vocabulary with the PRD's pay-run events; submission writes a snapshot for IRS immutability.

### Implementation Patterns
```
# Web page (clone QuickBooks recipe)
route admin/payroll.php  -> $authorize (StoreController + checkStoreGroup + checkAccess('view_pay_run'/'manage_payroll'))
                         -> new PayrollPageController($app,$store)->display<Page>()
controller display<Page>() -> checkAccess() -> buildPageData() {token:\NoCSRF::generate('csrf_token')}
                           -> $app->render('payroll/<page>.html', $data)
page JS  var PayrollRuns = (function(){ init(typeNum){ loadGrid(); } ... fetch(... , {body: JSON{csrf_token,…}}) })()

# Service → repository → DB statements; service composes audit + gate; repository owns SQL only.
# Webhook payment.* handler (mirror worker.* shape): match a line by evereePayableId/evereeWorkerId
#   -> no line: audit unmatched + markProcessed (no retry); matched-but-zero-row (status already applied):
#      idempotent no-op + markProcessed; genuine DB error: markFailed (retry).  (ADR-9b — three cases)
```

### Integration Points
- **Connection points:** admin pages → `PayrollAdminController`; `PayRunService` → `EvereeApiClient`/`TimesheetRepository`/`PayRateService`; webhooks → `ProcessEvereeWebhookJob` → `PayRunLineRepository`.
- **Data flow in/out:** approved hours (per-store) + rates (central) → run lines (central); submit → Everee + punch lock (per-store); payment webhooks → line status (central).
- **Events:** consumes `payment.*` + `worker.new-tax-forms-available`; emits `payroll.run.*` audit events.

---

## Architecture Decisions

- [ ] **ADR-1b** Reconcile the run state machine with the Phase-0 enum via an **append-only** migration. APPEND `pending_approval` + `partial_error` + `needs_reconciliation` at the END of `payrollRuns.status` and `returned` at the END of `payrollRunLines.status` (MySQL ENUM ordinals: appending never rewrites; mid-list insertion would). Keep the existing `funded` Everee state. PRD "error" maps to the existing `failed` (not a new value); `needs_reconciliation` is the uncertain-submit recovery state (ADR-5b/6b). Rationale: BK-side state machine post-dates the Phase-0 schema; append is non-destructive + idempotent. Trade-off: enum display order is non-semantic. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-2b** Hours sourcing — the approved weekly `scheduleTimesheets` totals (regular/OT/DT, already computed by `OvertimeCalculator` honoring `users.isExempt`) are **AUTHORITATIVE**; punches are used ONLY to ALLOCATE those weekly totals across `(store, position, day)` by hours-proportion, so the run total always reconciles to the approved total (no drift) while still resolving rates per position/work-date. A workweek straddling a semi-monthly boundary allocates its REGULAR hours by punch-DAY across the two periods; its OT/DT go to the period containing the week's `weekEndDate`. If punch-derived hours cannot reconcile to the approved weekly total → `Blocker(timesheet_reconciliation)`. Rationale: rate resolution needs day/position granularity, but the manager-approved totals must be the source of truth. Trade-off: ANY workweek whose OT-incurring hours span >1 distinct rate (multiple positions OR a single position's mid-week rate change) is BLOCKED in 050b (`multi_rate_overtime`; FLSA blended-rate deferred). User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-3b** Exempt semantics: `userStoreAssignments.employmentClassification` (per-store, Everee-facing) is BK-canonical; `users.isExempt` (global, read by `OvertimeCalculator`) is kept consistent (`w2_salaried` ⇒ `isExempt=1`) via a single `EmploymentClassificationService`. Because classification is per-store but `isExempt` is global, the service **rejects** setting a classification that conflicts with the same worker's classification at sibling stores of the same tenant (a worker cannot be hourly at store A and salaried at store B under one EIN) — classification is effectively tenant-uniform per worker. Rationale: divergence between the two columns would silently mis-pay. Trade-off: cross-store consistency check on every set (centralized + tested). User confirmed: 2026-06-04 (rvanvuren)
- [x] **ADR-4b** (REVISED 2026-06-04 — Payables API) Approval is **two-layered**: (1) BK-side `pending_approval` (our manager→owner gate; `approvedByUserId`/`approvedAt`) triggers the Payables submit; (2) Everee then **pauses the payment for its own payroll-admin approval in the Everee portal** before any money moves (confirmed in the Payables guide). BK depends on no Everee *programmatic* approval endpoint; the Everee admin approval is a portal step observed via the `payment*` webhooks. Rationale: matches PRD + the real Payables flow; the second layer makes even a wrong BK submit recoverable at Everee. Trade-off: BK `submitted` ≠ money sent. User confirmed: 2026-06-04 (rvanvuren)
- [x] **ADR-5b** (REVISED 2026-06-04 — real Everee **Payables API**; see `docs/interfaces/everee-payables-api.md`) Submit is **two-step**: `EvereeApiClient::createPayablesBulk` (one `PRE_CALCULATED` payable per line, **atomic**) → `createPayablePaymentRequest` (returns `PayablePaymentRequestDTO.id` = `payrollRuns.evereePayRunId`). **Idempotency = a deterministic per-payable `externalId`** `bk-payrun-{runId}-line-{lineId}` (there is **NO `Idempotency-Key` header**); re-POST is safe (atomic + dedup-by-externalId). The **authoritative recovery probe** (the "AUTHORITATIVE not-submitted" signal the lock-release predicate needs) is `GET /payables/{externalId}`: present with a `payablePaymentRequestId` ⇒ landed; absent ⇒ never landed; else still uncertain. On timeout the client throws `EvereeUncertainStateException`; release locks ONLY on an authoritative absent-probe, else stay `needs_reconciliation` (locks held). Everee ALSO pauses every payment for its own admin-portal approval (ADR-4b), so a `submitted` run cannot move money until that second approval — `submitted` ≠ paid. Rationale: matches the documented Payables flow; externalId + the GET probe give a concrete authoritative check (no imagined Idempotency-Key). User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-6b** Immutability at submit, in this ORDER: snapshot → **lock punches** (`submittedToEvereeAt` + new `submittedToEvereeRunId = run.id`, run-attributable) → **`status=needs_reconciliation` marker** → Everee submit → on success central `status=submitted`; on **definitive failure** (auth/validation) **release** the lock for `run.id`; on **uncertain** keep the lock and reconcile via `getPayRun(key)`. The lock PRECEDES the Everee call so there is NO window where Everee has accepted payroll while punches remain editable (§6: "no edits after submission"); run-attribution lets a definitively-failed submit cleanly release it. A durable `needs_reconciliation` marker is written BETWEEN the lock and the network call, so recovery distinguishes "submit may have been sent" (`needs_reconciliation`) from "submit never started" (status still `draft`/`pending_approval`) and releases locks ONLY when non-submission is authoritative or no attempt was durably started. Rationale: §6 forbids an editable-after-accept window; the pre-call marker makes every crash-residual safe. Trade-off: an extra central write per submit. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-7b** Web pages clone the QuickBooks admin recipe (self-contained Twig + IIFE JS + `\NoCSRF` single-use token + EJ2 grid for the runs list). 6 pages under `/admin/:typeNum/payroll`. Rationale: house consistency; reuses the reviewed pattern. Trade-off: page-JS duplication (acceptable; matches the codebase). User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-8b** Pay-period generation: new `PayPeriodCalculator` derives concrete `(periodStart, periodEnd)` from `payrollTenants.payFrequency` + `payPeriodEndsOnDayOfWeek` (weekly) + new `payPeriodAnchorDate` (REQUIRED for biweekly — disambiguates which 14-day window) + new `semiMonthlySecondPeriodStartDay` (default 16; periods are `[1..day-1]` and `[day..EOM]`); monthly = `[1..EOM]`. Owner selects a generated period (no arbitrary dates). Rationale: PRD decision; biweekly/semi-monthly are not derivable without an anchor/boundary. Trade-off: two new tenant columns (set at provisioning). User confirmed: 2026-06-04 (rvanvuren)
- [x] **ADR-9b** (REVISED 2026-06-04) New `payment.*`/`worker.new-tax-forms-available` handlers extend `ProcessEvereeWebhookJob` (remove from `DEFERRED_PHASE_1B_EVENTS`), matching a line by our deterministic **`externalId`** (`bk-payrun-{runId}-line-{lineId}`, derivable from runId+lineId) and/or `evereeWorkerId`. THREE distinct outcomes (no retry storms): no-matching-line → audit `unmatched` + `markProcessed` (no retry); matched-but-already-applied (zero-row UPDATE) → idempotent no-op + `markProcessed`; genuine DB error → `markFailed` (retry). A payable's `paymentStatus` of `ERROR`/`UNPAYABLE_WORKER` drives its line to `failed` and the run roll-up to `partial_error` — **`partial_error` comes from per-payable status, NOT a synchronous bulk-create partial** (create is atomic; ADR-5b). Webhook payload field names still CON-10. Rationale: completes submitted→paid UX; preserves Phase 1a idempotency/isolation. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-10b** Tenant-wide runs require **full-tenant access**: a new `TenantStoreResolver` enumerates the tenant's BK-native stores (filtering WIW/Homebase) and the controller verifies the actor holds the required permission at EVERY one before create/submit/approve; missing access → 403 (blocking stores named), no partial run. Rationale: PRD; preserves the `(tenant, period)` run identity. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-11b** Sandbox enforcement needs a REAL signal: add `payrollTenants.environment ENUM('sandbox','production') DEFAULT 'sandbox'`. `PayrollReadinessGate` asserts `environment='sandbox'` AND (defense-in-depth) the `EvereeApiClient` base URL resolves to the sandbox host before any onboarding kickoff or submit. `isActive` (reachability) is NOT an environment signal. 050b provisioning always sets `sandbox`; only the future production phase flips it. Rationale: PRD F5 cannot be enforced off `isActive` alone. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-12b** Worker exclusion is DURABLE: a new `payrollRunExclusions(payrollRunId, userId, reason, excludedByUserId, excludedAt)` table (UNIQUE per run+user). Preview and submit-payload construction READ it; excluded workers' punches are NOT locked, so their hours carry to a future run. Rationale: an audit row alone cannot drive a regenerated preview or the submit payload. Trade-off: one new table. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-13b** Line grain = `(userId, typeNum, positionId, rate-segment)`: add `typeNum` + `positionId` + `rateType` + `rateCents` to `payrollRunLines`. `positionId` is a PER-STORE local id, qualified by `typeNum`; a `(worker, store, position)` pair splits into sub-lines on a mid-period rate change. Because `payRateHistory` keys only `(userId, payrollTenantId, positionId)` (no store), the resolver detects a cross-store collision — the SAME `userId`+`positionId` arising from two different stores in one tenant-wide run — and raises `Blocker(position_ambiguous)` rather than resolve the wrong rate (the Phase-1a key weakness is logged as tech debt; the common single-store case is unambiguous). Salaried-exempt period gross divides `annualCents` over the **actual number of pay periods whose end falls in that payroll year** (52 or 53 weekly, 26 or 27 biweekly, else 24/12) with deterministic remainder distribution (first `annual % count` periods get +1 cent) so the annual sum is EXACT even in 53rd/27th-period years. Rationale: per-store position ids + the 53rd/27th-period reality. User confirmed: 2026-06-04 (rvanvuren)
- [ ] **ADR-14b** Draft idempotency on `(tenant, period)`: a `SELECT … FOR UPDATE` over ABSENT rows does NOT gap-lock reliably, so create acquires a serializing lock FIRST — a MySQL named advisory lock `GET_LOCK('payrun:<tenantId>:<periodStart>:<periodEnd>')` (released in `finally`) OR a `SELECT … FOR UPDATE` on the PARENT `payrollTenants` row — then checks for an existing non-cancelled run for the period and returns it or INSERTs. NOT a hard UNIQUE index (a cancelled run must allow re-creation for the same period). Rationale: concurrent "Create run" must not duplicate; cancel→recreate must work; gap-locking absent rows is unreliable. Trade-off: a short per-tenant create lock (run creation is rare). User confirmed: 2026-06-04 (rvanvuren)

---

## Quality Requirements
- **Performance:** a run preview for a typical store (≤ ~50 workers, ≤ 4 weeks) returns in < 1.5 s; rate lookups use the composite index; no N+1 across workers (batch rate + timesheet fetch).
- **Usability:** dry-run preview shows per-line math + blockers before any submit; sandbox state is unmistakable; modals apply the backdrop fix; the runs grid survives the hidden-tab RAF-defer bug.
- **Security:** 100% of mutating routes carry permission + store-group + CSRF + gate + readiness (route/middleware tests); zero plaintext token/PII exposure (scanner over the new files); the readiness gate blocks every non-sandbox mutation.
- **Reliability:** submit is idempotent (no double-pay under retry/timeout); a submitted run is immutable except via `payment.*` webhooks; one webhook's failure never blocks others; gross-pay matches golden fixtures (hourly, hourly+OT, salaried-exempt, multi-position, straddling-week).
- **Operability:** every lifecycle transition audited; snapshots queryable; `partial_error`/`returned` states visible in the grid for CS.

---

## Risks and Technical Debt

### Known Technical Issues
- The Everee pay-run submission API + the 5 `payment.*` webhook payloads are **uncaptured** (CON-10) — implementation against the documented model risks fixture-vs-reality drift (the trap that bit Phase 1a twice). Mitigation: live sandbox capture before SDD finalization; real fixtures; write smoke.
- Semi-monthly/monthly periods straddle workweeks; the OT-allocation rule (ADR-2b) must be golden-tested or it silently mis-pays at boundaries.

### Technical Debt
- `employmentClassification` ↔ `users.isExempt` are two columns expressing overlapping intent; ADR-3b centralizes consistency but the duplication remains until a future unification.
- `payRateHistory` keys `(userId, payrollTenantId, positionId)` but `positionId` is a per-store local id; a single user with colliding local position ids across two stores of one tenant is unresolvable (handled by `Blocker(position_ambiguous)` in 050b). A future fix globally qualifies positions or adds `typeNum` to the rate key.
- Web pages duplicate page-JS boilerplate (house pattern); acceptable, not refactored here.

### Implementation Gotchas
- `\NoCSRF::generate('csrf_token')` is single-use per render; the long-lived run-detail page must refresh the token after each POST (csrf-token-refresh skill) or the second action 403s.
- Central-DB migrations must use `alter_table` (no `alter_column` case) — applies to the enum-extension migration.
- Slim 2 `$app->halt($status, json_encode($x))` must pass the body (empty body breaks `dataType:json` clients) — the 050 halt() gotcha.
- Cross-DB submit spans the per-store DB (punch lock) and central DB (run rows). Correct order is **snapshot (central) → lock punches run-attributed (per-store) → status=needs_reconciliation marker (central) → Everee submit → status=submitted (central)**: the lock PRECEDES submit so there is no editable window after Everee accepts (§6), and the pre-call marker lets recovery tell "submit may have started" from "never started." A **definitive** submit failure RELEASES the run's locks (`WHERE submittedToEvereeRunId = run.id`); an **uncertain** submit keeps them and reconciles via `getPayRun`. A crash mid-sequence is repaired by run-attributed reconciliation. Do NOT submit before locking.
- ENUM extension must **APPEND** new values at the end (MySQL ENUM ordinals): inserting `pending_approval`/`partial_error` mid-list would shift ordinals and rewrite the table. Append-only is the safe form (ADR-1b).
- `employmentClassification` is per-(user,store) but `OvertimeCalculator` reads the GLOBAL `users.isExempt`; a worker with conflicting classifications across a tenant's stores would silently mis-pay. `EmploymentClassificationService` rejects a classification that conflicts with the worker's classification at sibling stores of the same tenant and writes `users.isExempt` to match (ADR-3b).

---

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Happy-path end-to-end run (gross correctness)**
```gherkin
Given a sandbox tenant with 1 onboarded "ready to pay" hourly worker and a set rate
And an approved timesheet for the pay period with regular + overtime hours
When the owner creates a run, previews it, and submits
Then the preview gross equals the hand-calculated integer cents (regular×rate + OT×rate×1.5)
And submit is idempotent (one Everee submission), status -> submitted, a pre_submit snapshot exists
And the included punches have submittedToEvereeAt set
```
**Scenario 2: Missing rate blocks (no silent $0)**
```gherkin
Given a worker with approved hours but no resolvable rate for the worked position
When a run is built
Then a blocking line error (missing_rate) is present, canSubmit=false, and submit is refused (422)
```
**Scenario 3: Not-ready worker blocks unless audited-excluded**
```gherkin
Given a worker with approved hours but onboarding incomplete
When the owner tries to submit
Then submit is blocked naming the worker
And only after an explicit audited exclude (reason recorded) can the run submit, with that worker's hours carried/flagged
```
**Scenario 4: Salaried-exempt proration + no overtime**
```gherkin
Given a w2_salaried (isExempt=1) worker with salary_annual rate
When a run for a period is built
Then the line gross = the worker's payroll-year per-period share (annualCents over the year's ACTUAL period count, remainder-distributed; ADR-13b), no overtime, exempt captured in the line snapshot
```
**Scenario 5: Approval state machine**
```gherkin
Given a manager holding submit_pay_run but not approve_pay_run
When they submit a clean run
Then status becomes pending_approval (no Everee call)
And an owner with approve_pay_run approving triggers the Everee submission -> submitted
```
**Scenario 6: Above-threshold gate**
```gherkin
Given tenant.twoPersonApprovalThresholdCents is set and run total exceeds it
When an approver lacking approve_pay_run_above_threshold approves
Then it is refused (403); with the higher permission it proceeds
```
**Scenario 7: Idempotent submit under timeout**
```gherkin
Given the Everee submit call times out after sending
When submit retries
Then getPayRun(idempotencyKey) is consulted; exactly one Everee submission results; never marked submitted on true uncertainty
```
**Scenario 8: Tenant-wide full-access (no partial run)**
```gherkin
Given a tenant with two BK-native stores and an actor lacking access at one
When they create a run
Then it is refused (403) naming the blocking store; no run row is created
```
**Scenario 9: Stale preview**
```gherkin
Given a previewed run, then a rate changes
When the owner opens run-detail
Then the preview is recomputed fresh (stale=false on serve); submit always uses fresh+snapshotted state
```
**Scenario 10: payment.* webhook drives line status**
```gherkin
Given a submitted run with lines
When payment.paid arrives matching a line (by evereePayableId)
Then that line.status -> paid; when payment.deposit-returned arrives later, line.status -> returned (high-visibility), audited, nothing silently overwritten
```
**Scenario 11: Partial payable failure (per-payable status, not a sync partial)**
```gherkin
Given the bulk create is atomic (all payables created or none) and the payment-request is issued
When one payable's paymentStatus later becomes ERROR/UNPAYABLE_WORKER (via webhook/GET) while others proceed
Then that line -> failed and the run rolls up to partial_error; accepted lines proceed; none silently marked paid
```
**Scenario 12: Unmatched payment event**
```gherkin
Given a payment.* event referencing an Everee id mapping to no line
Then it is logged + audited as unmatched, acknowledged without raising, and does not block other events
```
**Scenario 13: Readiness gate (sandbox-only)**
```gherkin
Given a tenant not in sandbox state
When any onboarding kickoff or submit is attempted
Then it is refused (readiness_blocked) + audited; the gate is a single seam
```
**Scenario 14: Classification mismatch**
```gherkin
Given a w2_salaried worker
When a user tries to set an hourly rate (or sets classification inconsistently with isExempt)
Then it is rejected (classification_mismatch); EmploymentClassificationService keeps isExempt consistent
```
**Scenario 15: Cancel semantics**
```gherkin
Given a submitted run with no payments
When cancelled -> succeeds (cancelled); once any payment exists -> 409 cancel_not_permitted
```
**Scenario 16: Biweekly period generation (anchored)**
```gherkin
Given a tenant payFrequency=biweekly with payPeriodAnchorDate set
Then PayPeriodCalculator yields 14-day periods stepping from the anchor; the owner can only select generated periods
```
**Scenario 17: Salaried remainder distribution (incl. 27th-period year)**
```gherkin
Given a salaried worker and a payroll year with 27 biweekly period-ends (a 27th-period year)
Then annualCents is divided over the ACTUAL 27 periods with deterministic remainder distribution
And the sum across that payroll year's periods equals annualCents EXACTLY (golden fixture)
```
**Scenario 18: Semi-monthly straddling-week OT allocation**
```gherkin
Given a semi-monthly period whose boundary falls mid-workweek and that week incurred OT
Then REGULAR hours are split by punch-day across the two periods
And OT/DT stay attached to the earning week's period (documented rule); gross is hand-verifiable
```
**Scenario 19: Multi-position / mid-period rate change**
```gherkin
Given a worker who worked two positions and whose rate for one changed mid-period
Then lines are emitted per (worker, position, rate-segment), each with its as-of rate
And a single-rate week computes cleanly; a multi-rate week WITH overtime raises Blocker(multi_rate_overtime)
```
**Scenario 20: Exclusion persistence across regenerated preview**
```gherkin
Given a worker excluded from a run with a reason
When the preview is regenerated and the run is submitted
Then the exclusion persists (payrollRunExclusions), the worker is omitted from the submit payload
And their punches are NOT locked (hours carry to a future run)
```
**Scenario 21: Concurrent create is idempotent**
```gherkin
Given two simultaneous "Create run" calls for the same (tenant, period)
Then the serializing lock (advisory GET_LOCK / parent-row FOR UPDATE) yields exactly one draft; the second returns it
And after cancelling that run, a new run for the same period CAN be created
```
**Scenario 22: Cross-DB submit recovery (lock-before-submit)**
```gherkin
Given punches are locked (submittedToEvereeRunId=run.id) BEFORE the Everee submit
When the submit definitively fails -> the run's locks are released (run stays draft/pending)
When the submit is uncertain -> getPayRun(key): authoritatively-not-submitted -> release;
  else -> locks kept and the run enters needs_reconciliation (visible, audited) until a backoff recovery resolves it
And a crash AFTER the step-7 marker leaves the run needs_reconciliation+locked -> reconciliation verifies via getPayRun (release ONLY if authoritatively not-submitted)
And a crash BEFORE the marker (status still draft/pending) means no submit started -> locks safely released
And re-submit with the same key never double-pays
```
**Scenario 23: Sandbox-gate is environment-based (no false negatives/positives)**
```gherkin
Given a tenant with isActive=1 but environment=production
Then the readiness gate BLOCKS onboarding/submit (isActive is not an environment signal)
And a sandbox tenant with environment=sandbox AND sandbox base-URL is permitted
```

### Test Coverage Requirements
- **Business logic:** period generation per frequency; gross math (hourly/OT/DT/salaried) golden fixtures; straddling-week allocation; blocker detection; state-machine transitions.
- **Integration:** approved-timesheet → preview → submit → webhook → line-status, end to end on fixtures; idempotent submit under simulated timeout.
- **Security:** every mutating route gated (route/middleware tests); readiness gate; token-leak scanner over new files; `grep catch(\Exception` returns zero in new `Payroll/` code.
- **Edge cases:** empty period (no approved hours), all-excluded run, semi-monthly boundary, concurrent create (idempotent draft), backdated-rate-after-submit immutability.

---

## Glossary

### Domain Terms
| Term | Definition | Context |
|------|------------|---------|
| Pay run | A tenant-wide payroll for one pay period | `payrollRuns` row + lines |
| Pay period | A date range generated from the tenant pay frequency | `PayPeriodCalculator` |
| Ready to pay | Worker onboarded + TIN verified + rate set | roster + run blockers |
| Tombstone | A retirement marker in append-only rate history | `PayRateService` |
| Workweek OT | Overtime computed weekly (FLSA/state) | `OvertimeCalculator` |

### Technical Terms
| Term | Definition | Context |
|------|------------|---------|
| Dry-run preview | Per-line + total gross, computed with no Everee call | run-detail page |
| Lock signal | `scheduleTimePunches.submittedToEvereeAt` set at submit | immutability |
| Readiness gate | Single seam enforcing sandbox-only | `PayrollReadinessGate` |
| partial_error | Run state: Everee accepted some lines, rejected others | ADR-1b |

### API / Interface Terms
| Term | Definition | Context |
|------|------------|---------|
| `submitPayRun` | NEW Everee client write (partner-gated shape) | `EvereeApiClient` |
| Idempotency-Key | `payrun-submit:<runId>` for safe retry | submit path |
| `evereePayableId` | Everee per-worker payment id matched by webhooks | line status |
