# Port 01 — Scheduling & Payroll Export

First module ported under the [BuyerKiosk → Alqove porting framework](buyerkiosk-porting-framework.md).
Source inventory performed 2026-09-02 against `~/Projects/buyerkiosk-web`
(`userfrosting/src/BuyerKiosk/Scheduling/` + `MobileScheduling/` + related).

## Recommended scope

BK's full scheduling suite is enormous (shifts, punches, timesheets, availability,
swaps, offers, time-off, templates, recurrence, AI scheduling, mobile app, realtime).
**Recommendation: port the lean core that delivers the payroll-export outcome**, then
grow. The lean core is Phases 0–4 below; everything else is explicitly deferred with
its BK source noted so nothing is lost.

Target shape: `app/Modules/Scheduling/` (shifts + punches) and
`app/Modules/Payroll/` (timesheets + export). Shared models in `app/Models/`.

**Hard prerequisite: [Port 00 — Employees & Store Access](port-00-employees-store-access.md)**
(memberships, `StoreRole` presets, `StoreCapability` + `EnsureStoreCapability`,
pay rates, positions, invitations, `(staff)` shell). All people-references below are
`store_membership_id`; all endpoint gating uses Port 00 capabilities:
`schedule.manage`/`schedule.publish`/`punches.manage`/`timesheets.approve` (owner +
manager presets), `pay.*` and `payroll.export` (owner preset only), `store.member`
for the `/my/...` self-service routes.

## Phase 0 — Scheduling settings (small prerequisite PR)

| Alqove change | Sourced from BK | Notes |
|---|---|---|
| `stores/store_settings.timezone` | `Store::getTimeZone()` (default America/Los_Angeles) | IANA tz column; seller-editable. Everything downstream depends on it. |
| Scheduling settings on `store_settings` | `stores` columns from `20251220_013_009` | v1 subset only: `work_week_start_day` (enum), `work_week_start_time` (TIME), `payroll_rounding_mode` (enum none/nearest/up/down), `payroll_rounding_increment_minutes`, clock windows (`clock_in_early_minutes`, `clock_in_late_minutes`, `clock_out_late_minutes`). Typed columns, fail-closed defaults. |

## Phase 1 — Shifts & schedule

**Backend** (`app/Modules/Scheduling/`):

| Alqove table | From BK | Semantics to keep |
|---|---|---|
| `shifts` | `scheduleShifts` (model `Scheduling/Models/Shift.php`, repo `ShiftRepository.php`) | `store_id`, nullable `store_membership_id` (**null = open shift**), `starts_at`/`ends_at` UTC, `position_id`, notes, `published_at`/`published_by`, soft delete, `created_by`. Drop for v1: recurrence, AI columns, keyholder/opener/closer flags, claim. |
| `schedule_publications` | `schedulePublished` (`..022_010`) | One row per published week (`store_id` + `week_start` unique), counts. Publishing = stamping `published_at` on the week's shifts + a publication row, in one transaction. |

Services (fidelity: PORT VERBATIM the math, transliterate the rest):
- `WorkWeek` — port from BK `Services/WorkWeek.php` (`labelWeekStart` :81,
  `weekStartForInstant` :100, `nextWeekStart` :122). The DST cutoff rules are the
  battle-tested core; keep the exact semantics and port `WorkWeekTest.php`.
- `CopyWeekService` — from `ShiftRepository::copyWeek` (:717) + `calculateDayOffset`
  (:890): UTC→store-local, offset in local tz, back to UTC (9 AM stays 9 AM across DST).
- Overlap validation, publish flow. Audit via `activity('scheduling')` calls.

Endpoints under `/v1/stores/{store}/schedule/...`: shifts CRUD, week view
(`?week_start=`), copy-week (preview + commit), publish week, positions filter.
Concurrency: shift updates carry an `updated_at` revision token (BK microsecond
pattern → If-Unmodified-Since-style check in the FormRequest/Service).

**Frontend**: `(seller)/seller/schedule/` — week grid (employees × days; bespoke
component — no calendar lib exists; plain CSS grid beats importing Syncfusion),
shift create/edit dialog, copy-week and publish actions. Nav entry "Schedule"
(visible to owner + manager per capability matrix).
**Staff side**: `(staff)` "My Schedule" tab — published shifts for the selected
store (`GET /v1/stores/{store}/my/schedule`), plus read-only team view of the
published week (any member, per matrix).

## Phase 2 — Time punches (employee self-clock + manager corrections)

| Alqove table | From BK | Semantics to keep |
|---|---|---|
| `time_punches` | `scheduleTimePunches` (`TimePunchRepository.php`) | `store_id`, `store_membership_id`, nullable `shift_id`, `punch_type` enum(clock_in, clock_out, break_start, break_end), `punched_at` UTC, `break_type` enum nullable, manual-entry + manager-override + edit/delete audit fields. **Keep the dedupe unique key** (membership, type, second) so double-submits are no-ops (`20260711_001`). Drop: GPS/geofence columns for v1 (revisit with mobile hardening). |

Two entry paths from day one:
- **Employee self-clock** on the `(staff)` surface — the headline reason employees
  have logins. `GET /v1/stores/{store}/my/clock` (current status, BK
  `MobileClockService::getClockStatus` :107 semantics) +
  `POST /v1/stores/{store}/my/clock` (next-punch state machine: clock_in →
  break/clock_out …, BK `clockIn` :202). Server derives the punch type from current
  state — the client never chooses it (prevents out-of-order punches). Clock windows
  from settings enforce early/late bounds; outside the window → 422 with a clear
  message, manager can override via correction (`require_manager_override` semantics
  from BK, fail-closed).
- **Manager corrections** in the seller UI (BK `TimesheetController::editPunch/
  addPunch/deletePunch` flows): manual add/edit/delete with note + full audit trail,
  `manager`+ per matrix.

Punch validation: transaction + unique constraints first (BK used MySQL advisory
locks; escalate only if contention shows up). A punch belongs to the membership
resolved from auth — never a client-supplied member id on the self path.

Endpoints: `/v1/stores/{store}/my/clock` (self), `/v1/stores/{store}/punches` CRUD
(manager corrections, list by member/date range).
Frontend: `(staff)` Clock tab (big state-driven button + today's punches);
manager punch editing lives inside the Phase 3 timecard detail screen.

## Phase 3 — Timesheets & overtime

| Alqove table | From BK | Semantics to keep |
|---|---|---|
| `timesheets` | `scheduleTimesheets` (`TimesheetRepository.php`) | Unique (`store_id`, `store_membership_id`, `week_start_date`). Scheduled vs actual regular/overtime/doubletime **minutes** (BK DECIMAL(8,4) hours → integer minutes), `unpaid_break_minutes`, `total_pay_cents` nullable (= no rate configured), `status` enum(pending, approved, exported), approve/export stamps, `calculation_metadata` JSON. |
| `overtime_rules` | `kiosk_users.scheduleOvertimeRules` | Per store, `effective_start_date`, preset key (US-FLSA default, US-CA, CA-ON…) or custom typed rule object. BK's untyped `ruleJson` → a typed value object / DTO with schema version. |

Services:
- `OvertimeCalculator` — PORT VERBATIM from BK `Services/OvertimeCalculator.php`:
  daily-OT-first then weekly-on-remaining-regular, countable-hours-per-day cap,
  consecutive-day (7th-day) overlay, doubletime multiplier, exempt short-circuit
  (:215), UTC-instant punch windows (:163-170), (punchTime, punchId) ordering.
  Port `OvertimeCalculatorTest.php` + `TimesheetRecalculationInvariantTest.php` first.
- `TimesheetRecalculationService` — recalc on punch/rate change.
- Lifecycle guards (from `TimesheetController`): approve refuses exported rows
  (:1098), **stranded clock-in hard-blocks approve and export** (:1069, :1440),
  oversized sessions soft-block with acknowledgement (:1461), unlock only from
  approved (:1222).

Deferred from BK: the break-policy engine (Spec-049 `BreakPolicy/`) — v1 uses
explicit paid/unpaid `break_type` on punches only.

Endpoints: `/v1/stores/{store}/timesheets` (week list, detail with punch breakdown,
approve, unlock, recalculate) — `manager`+ per matrix. Self-service:
`GET /v1/stores/{store}/my/timesheets` (own weeks, hours; pay visible to self).
Frontend: `(seller)/seller/timesheets/` — week list + per-member detail (punch
table with inline edit), approve flow. Nav entry "Timesheets". `(staff)` gets a
read-only "My Hours" view.

## Phase 4 — Payroll CSV export

The reason we're here. Two pieces:

| Alqove table | From BK | Semantics to keep |
|---|---|---|
| `payroll_exports` | `scheduleTimesheetExports` (`20260624_001` + `20260831_004`) | **Dedicated table, not activitylog** — it's a durability mechanism. `store_id`, exporter, period bounds, week starts JSON, counts, `total_minutes`/`total_pay_cents`, rounding mode/increment, filename, timesheet ids JSON, **`idempotency_key` UNIQUE, `request_hash`, `csv_hash`, immutable `export_details` snapshot, conflict quarantine key**. |

Service `PayrollExportService` — PORT VERBATIM the at-most-once scheme from BK
`TimesheetController::exportTimesheets` (:1278) + `TimesheetExporter`:
1. Require idempotency key (the alqove api-client already auto-sends
   `Idempotency-Key` on POST — `client.ts:86-90`; server must honor it, or route
   through the existing `idempotency` middleware if compatible).
2. Replay is answered from the immutable `export_details` snapshot **before reading
   any mutable timesheet/rate/store data**; verify `csv_hash` on rebuild.
3. Same key + different `request_hash` → 409 conflict.
4. Mark-exported + export row insert in **one transaction**.
5. Rounding: quantize components before deriving totals
   (`TimesheetExporter::buildRoundedHours` :560); largest-remainder for period
   aggregates (:333). CSV body never persisted — rebuilt from durable row snapshots.

Endpoints (**owner-only** — money boundary per the Port 00 matrix):
`POST /v1/stores/{store}/payroll/exports` (create/replay),
`GET .../exports` (history), `GET .../exports/{id}/download` (CSV stream —
mirror `StoreLedgerController::exportCsv` `streamDownload` + `fputcsv`).
Frontend: `(seller)/seller/payroll/` — export form (period picker, native date
inputs), acknowledgement re-submit flow for soft-blocks, history table, download
via the `getBlob` → object-URL pattern from `seller-statements-client.tsx:124-146`.
Nav entry "Payroll".

Tests to port (highest value): `TimesheetControllerIdempotencyReplayTest`,
`PayrollExportGateTest`, `StrandedClockInTest`, `OversizedSessionTest`,
`WeekBoundaryTest`, `WeekStartSemanticsTest`, `TimezoneTest`, `OvernightTest`,
`TimesheetExporterTest`, `TimesheetPayrollExportPersistenceTest`. Plus an e2e QA
spec against the real stack (playwright.qa.config style) for the export flow.

## Explicitly deferred (BK source preserved for later ports)

| Feature | BK source | Why deferred |
|---|---|---|
| Availability & exceptions | `scheduleAvailability*`, `AvailabilityService` | Not needed for payroll outcome |
| Swap / offer / time-off approvals | `ShiftSwapService`, `ShiftOfferService`, `TimeOffService` (+policies/blackouts) | Natural port-02 candidate now that staff logins exist — needs only the (staff) surface + manager queue |
| Open-shift claiming | `OpenShiftService::claimShift` + `openShiftClaimPolicy`, `open_shift_min_role` | Maps cleanly onto StoreRole tiers (shift_lead priority); with swaps in port-02 |
| Templates & recurrence | `TemplateService`, RRULE columns | Schedule-building convenience, later |
| Break-policy engine | `Services/BreakPolicy/` (Spec-049) | v1 explicit break types suffice |
| Kiosk clock mode + GPS/geofence | Workbook panel, `GeofenceService`, punch GPS columns | Staff self-clock covers v1; shared-device kiosk mode can ride `pos.device` auth later |
| AI scheduling | `AiScheduling/` (OpenAI + OR-Tools) | Separate product decision |
| Realtime sync + push/SMS | Ably/Firebase services | No realtime channel in Alqove; refetch UX in v1 |
| Statutory holidays mode | `stores.statutoryHolidays*` | Add with the first store that needs it |

## Dropped outright (do not port)

Multi-DB plumbing; WIW/Homebase provider abstraction + `schedulingProvider` gating
(Alqove has one provider: itself); `workbook_punch_log` dual-write; the
`NATIVE_PAYROLL_EXPORT_AUTHORITATIVE_STORES` env allowlist; Slim route-order hacks;
`'wiw'` vs `'wheniwork'` string drift (backed enums prevent the class); Syncfusion.
