# Solution Design Document
# 035 - Premium Scheduling Module

## 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**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

CON-1 **Framework/Language**: PHP 8.x, Slim 2.6.2, Twig 1.44.8, MySQL, Redis. No framework migration.
CON-2 **No payment processor**: Premium billing is a line item on existing BK invoices. No Stripe/PayPal integration. Manual `premiumStatus` flag in database.
CON-3 **Migration system**: All schema changes MUST use the conductor migration JSON system (`userfrosting/migrations/input/`). No direct table modifications.
CON-4 **Central database**: Premium status stored in `kiosk_buykiosk.stores` table (per-store, not per-user). This is a new pattern — existing feature flags use env vars only.
CON-5 **Mobile coordination**: Both BuyerKiosk Team and Live apps consume the mobile API. Premium gating must work at the API level so even outdated mobile apps get proper 403 responses.
CON-6 **TaskEngine dependency**: Trial expiration requires a daily scheduled job. The TaskEngine (spec 015) scheduler infrastructure must be running.
CON-7 **Existing permissions**: The `uri_schedule`, `uri_schedule_manage`, `uri_schedule_config`, `uri_schedule_ai` permission hooks must continue to work. Premium gating is an additional layer on top of permissions.
CON-8 **Bootstrap 5 + design tokens**: All UI must use the existing design system (`tokens.css`, `admin-theme.css`). Syncfusion components preferred over custom implementations.

## Implementation Context

### Required Context Sources

- ICO-1 General Application Context
  ```yaml
  - doc: docs/specs/035-premium-scheduling-module/product-requirements.md
    relevance: CRITICAL
    why: "Complete PRD defining what/why for premium gating"

  - doc: CLAUDE.md
    relevance: HIGH
    why: "Architecture reference, commands, and conventions"
  ```

- ICO-2 Store Model
  ```yaml
  - file: userfrosting/src/BuyerKiosk/Core/Store.php
    relevance: CRITICAL
    sections: [lines 93-103 scheduling props, lines 2365-2401 feature flags]
    why: "Store entity holds schedulingProvider and will hold premium status"

  - file: userfrosting/migrations/input/20251220_013_009_store_scheduling_config.json
    relevance: HIGH
    why: "Existing scheduling config columns in stores table"
  ```

- ICO-3 Scheduling System
  ```yaml
  - file: userfrosting/routes/scheduling.php
    relevance: CRITICAL
    why: "All scheduling API routes that need premium gating"

  - file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/SchedulingPageController.php
    relevance: HIGH
    why: "Page controller that renders scheduling views — intercept point for marketing page"

  - file: userfrosting/routes/groups/ai-scheduling.php
    relevance: HIGH
    why: "AI scheduling routes that need premium gating"

  - file: userfrosting/routes/groups/staff-chat-api.php
    relevance: HIGH
    why: "Staff chat routes that need premium gating"

  - file: userfrosting/routes/admin/chat.php
    relevance: HIGH
    why: "Admin chat page route"
  ```

- ICO-4 Navigation & Sidebar
  ```yaml
  - file: userfrosting/templates/themes/default/menus/sidebar.html
    relevance: HIGH
    sections: [lines 231-262 scheduling section]
    why: "Sidebar nav items gated by uri_schedule — premium must hide these when inactive"
  ```

- ICO-5 Mobile API
  ```yaml
  - file: userfrosting/routes/groups/mobile.php
    relevance: HIGH
    why: "Mobile API routes need premium status in store info and 403 gating"

  - file: userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php
    relevance: HIGH
    why: "Controller returning store info to mobile apps"

  - file: userfrosting/src/BuyerKiosk/MobileApi/Middleware/HybridAuthMiddleware.php
    relevance: MEDIUM
    why: "Auth middleware pattern to follow for premium middleware"

  - file: userfrosting/src/BuyerKiosk/MobileApi/Middleware/StoreAccessMiddleware.php
    relevance: MEDIUM
    why: "Store access middleware pattern — similar gating approach"
  ```

- ICO-6 TaskEngine
  ```yaml
  - file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Jobs/BaseJob.php
    relevance: HIGH
    why: "Base class for creating trial expiration job"

  - file: userfrosting/src/BuyerKiosk/TaskEngine/Registry/TaskCommandFactory.php
    relevance: HIGH
    sections: [lines 220-264 job registration]
    why: "Where to register the new TrialExpirationJob"

  - file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Scheduler/Scheduler.php
    relevance: MEDIUM
    why: "Scheduler runs daily jobs"
  ```

- ICO-7 Permissions & Auth
  ```yaml
  - file: userfrosting/models/mysql/MySqlUser.php
    relevance: HIGH
    sections: [lines 313-360 checkAccess and checkStoreGroup]
    why: "Permission system that premium check layers on top of"
  ```

- ICO-8 Migration System
  ```yaml
  - file: userfrosting/migrations/methods/checkQuery.php
    relevance: MEDIUM
    why: "Migration skip-detection logic"

  - file: userfrosting/migrations/methods/ops/create.php
    relevance: MEDIUM
    why: "create_table operation handler"
  ```

- ICO-9 Existing Marketing Pages
  ```yaml
  - file: userfrosting/templates/themes/default/demo/landing.html
    relevance: MEDIUM
    why: "Reference for marketing page design patterns (Bootstrap 5, cards, CTAs)"
  ```

### Implementation Boundaries

- **Must Preserve**: All existing scheduling, chat, and AI functionality. Existing permission hooks (`uri_schedule`, `uri_schedule_manage`, etc.). Mobile API contract for existing endpoints (add fields, don't remove). Store.php backward compatibility.
- **Can Modify**: Store.php (add premium properties/methods). Sidebar template (add premium conditional). SchedulingPageController (add marketing page redirect). Mobile API store info response (add premium fields). Route files (add premium check wrapper).
- **Must Not Touch**: BaseModel.php core functions. UserFrosting auth core. External integration code (WIW, Homebase API clients). Existing migration files. Payment/billing system.

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Owner[Store Owner] --> WebUI[Web UI]
    Manager[Store Manager] --> WebUI
    TeamMember[Team Member] --> MobileApp[BuyerKiosk Team App]
    Admin[BK System Admin] --> AdminUI[Admin Dashboard]

    WebUI --> PremiumGate{Premium Gate}
    PremiumGate -->|Premium Active| SchedulingUI[Scheduling UI]
    PremiumGate -->|Not Premium| MarketingPage[Marketing/Upsell Page]

    SchedulingUI --> ScheduleAPI[Schedule API]
    SchedulingUI --> ChatAPI[Staff Chat API]
    SchedulingUI --> AiAPI[AI Scheduling API]

    MobileApp --> MobileAPI[Mobile API]
    MobileAPI --> PremiumCheck{Premium Check}
    PremiumCheck -->|Active| MobileData[Data Response]
    PremiumCheck -->|Inactive| Error403[403 premium_required]

    AdminUI --> AdminAPI[Admin Premium API]
    AdminAPI --> StoresDB[(kiosk_buykiosk.stores)]

    TaskEngine[TaskEngine Scheduler] --> TrialJob[Trial Expiration Job]
    TrialJob --> StoresDB
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Web UI (Session Auth)"
    type: HTTP/HTTPS
    format: Server-rendered HTML + REST API
    authentication: Session cookies (UserFrosting)
    data_flow: "Admin pages, scheduling API calls"

  - name: "Mobile API (JWT)"
    type: HTTPS
    format: REST JSON
    authentication: JWT (HybridAuthMiddleware)
    data_flow: "Store info with premium status, schedule/chat operations"

  - name: "Admin API (Session Auth)"
    type: HTTP/HTTPS
    format: REST JSON
    authentication: Session cookies + admin permissions
    data_flow: "Premium status management for stores"

# Internal Interfaces
internal:
  - name: "TaskEngine Scheduler"
    type: CLI / Redis Queue
    format: Job dispatch
    authentication: Internal (no auth)
    data_flow: "Daily trial expiration processing"

# Data Interfaces
data:
  - name: "Central Database"
    type: MySQL
    connection: PDO via dbConnectByName('kiosk_buykiosk')
    data_flow: "Premium status CRUD on stores table"

  - name: "Redis Cache"
    type: Redis
    connection: Predis client
    data_flow: "Premium status cache (60s TTL for <60s propagation)"
```

### Billing Integration (MVP)

Premium billing is NOT automated through a payment processor. The flow is:

1. **On trial start**: No billing action. The `premium_trial_started` event is logged to `premiumEventLog`.
2. **On premium activation** (owner clicks "Enable Premium"): The `premium_activated` event is logged. A BK operations team member monitors these events (or receives an internal notification) and manually adds the $30/mo line item to the store's existing BuyerKiosk invoice in the billing system.
3. **On premium deactivation**: The `premium_deactivated` event is logged. BK operations removes the $30/mo line item.
4. **On trial expiration**: The `premium_trial_expired` event is logged. No billing action (trial was free).
5. **On reactivation**: Same as activation — `premium_reactivated` event logged, operations adds billing line item.

**Internal notification mechanism**: The `PremiumEventLogger` sends a webhook/email to BK operations for `premium_activated` and `premium_deactivated` events. For MVP, this can be a simple email to an operations mailbox. Implementation detail: the `PremiumEventLogger::log()` method fires an internal notification for billing-relevant events.

**Why no automated billing**: Per PRD CON-1 ("No new payment processor"), premium is added to existing BK invoices. Automating this requires integration with the existing billing system, which is out of scope for MVP. The event log + notification provides a reliable audit trail for manual billing.

### Cross-Component Boundaries

- **API Contracts**: Mobile API premium status fields are a public contract. Once mobile apps consume `premiumScheduling`, `trialEndDate`, etc., these fields cannot be removed without mobile app coordination.
- **Shared Resources**: `kiosk_buykiosk.stores` table is read by web, mobile, and admin. Premium columns added here are consumed everywhere.
- **Breaking Change Policy**: Adding new fields to mobile API responses is non-breaking. Returning 403 on previously-accessible endpoints IS breaking — mobile apps must handle this gracefully.

### Project Commands

```bash
# Development
cd userfrosting && composer install

# Testing
./test.sh --testsuite unit              # All unit tests
./test.sh --testsuite integration       # Integration tests
cd userfrosting && ./vendor/bin/phpunit --filter "Premium"  # Premium-specific tests

# Database
php userfrosting/conductor run          # Run pending migrations

# CSS
php userfrosting/conductor build-css --minify  # Build CSS (if marketing page adds styles)

# TaskEngine
php userfrosting/bin/task job:list                          # Verify job registered
php userfrosting/bin/task job:dispatch trial-expiration      # Manual test dispatch
php userfrosting/bin/task scheduler:run                      # Run scheduler cycle

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Premium/
```

## Solution Strategy

- **Architecture Pattern**: Layered gating service with middleware integration. A `PremiumService` acts as the single source of truth for premium status, wrapping database reads with Redis caching. Route-level middleware and Twig template helpers consume this service.

- **Integration Approach**: Premium gating is an additional authorization layer that sits between the existing permission system and the feature controllers. It does NOT replace `checkAccess('uri_schedule')` — it adds a "is premium active?" check on top. This means:
  1. First: `checkStoreGroup($typeNum)` → user belongs to store
  2. Then: `checkAccess('uri_schedule')` → user has scheduling permission
  3. Then: `PremiumService::isPremiumActive($typeNum)` → store has premium

- **Justification**: This approach respects existing patterns (permissions are untouched), is backward-compatible (removing premium gating is just removing middleware), and centralizes premium logic (one service, one cache, one source of truth).

- **Key Decisions**:
  1. Premium status lives as columns on the existing `stores` table (not a separate table) — aligns with how `schedulingProvider` already works
  2. Redis cache with 60s TTL for premium status — meets the "within 60 seconds" propagation requirement
  3. Twig extension function `isPremiumActive()` for template-level gating — simplest approach for sidebar/nav
  4. TaskEngine job for trial expiration — consistent with existing job patterns (AI cleanup, chat retention, etc.)

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Web Layer"
        Sidebar[Sidebar Template]
        SchedPage[Schedule Page Controller]
        MarketingPage[Marketing Page]
        AdminPage[Admin Premium Panel]
    end

    subgraph "Middleware Layer"
        PremiumMiddleware[Premium Check Middleware]
        TwigExt[Twig Premium Extension]
    end

    subgraph "Service Layer"
        PremiumService[PremiumService]
        TrialService[TrialService]
        PremiumEventLogger[PremiumEventLogger]
    end

    subgraph "Data Layer"
        PremiumRepo[PremiumRepository]
        RedisCache[Redis Cache]
        StoresTable[(stores table)]
    end

    subgraph "Background"
        TrialExpJob[TrialExpirationJob]
    end

    Sidebar --> TwigExt
    SchedPage --> PremiumService
    SchedPage -->|not premium| MarketingPage
    AdminPage --> PremiumService

    PremiumMiddleware --> PremiumService
    TwigExt --> PremiumService

    PremiumService --> PremiumRepo
    PremiumService --> RedisCache
    TrialService --> PremiumRepo
    TrialService --> PremiumEventLogger

    PremiumRepo --> StoresTable
    TrialExpJob --> TrialService
```

### Directory Map

```
userfrosting/src/BuyerKiosk/Premium/                      # NEW: Premium module
├── PremiumService.php                                     # NEW: Core service (status checks, caching)
├── TrialService.php                                       # NEW: Trial activation/expiration logic
├── PremiumStatus.php                                      # NEW: Enum-like value object (none/trial/active/expired)
├── PremiumRepository.php                                  # NEW: Database queries for premium status CRUD
├── PremiumEventLogger.php                                 # NEW: Analytics event logging
├── TrialUsageService.php                                  # NEW: Trial usage stats (shifts, chats, AI)
├── Controllers/
│   ├── PremiumPageController.php                          # NEW: Marketing page rendering
│   └── PremiumApiController.php                           # NEW: API for trial/activation actions
├── Middleware/
│   └── PremiumGateMiddleware.php                          # NEW: Route middleware for API gating
├── Jobs/
│   └── TrialExpirationJob.php                             # NEW: Daily trial expiration TaskEngine job
└── Twig/
    └── PremiumTwigExtension.php                           # NEW: Twig functions for template gating

userfrosting/src/BuyerKiosk/Core/Store.php                 # MODIFY: Add premium properties/getters
userfrosting/src/BuyerKiosk/Scheduling/Controllers/
    SchedulingPageController.php                           # MODIFY: Intercept for marketing page redirect
userfrosting/src/BuyerKiosk/MobileApi/Controllers/
    MobileApiController.php                                # MODIFY: Add premium fields to store info
userfrosting/src/BuyerKiosk/TaskEngine/Registry/
    TaskCommandFactory.php                                 # MODIFY: Register TrialExpirationJob

userfrosting/routes/
├── premium.php                                            # NEW: Premium API routes
├── scheduling.php                                         # MODIFY: Add premium middleware wrapper
├── groups/ai-scheduling.php                               # MODIFY: Add premium middleware wrapper
├── groups/staff-chat-api.php                              # MODIFY: Add premium middleware wrapper
└── admin/
    ├── scheduling.php                                     # MODIFY: Intercept for marketing page
    └── premium-admin.php                                  # NEW: Admin premium management routes

userfrosting/templates/themes/default/
├── menus/sidebar.html                                     # MODIFY: Add premium conditional around scheduling
├── premium/
│   ├── marketing.html                                     # NEW: Marketing/upsell landing page
│   ├── partials/
│   │   ├── hero-section.html                              # NEW: Hero with value prop headline + subheading
│   │   ├── feature-grid.html                              # NEW: Feature grid with screenshots
│   │   ├── pricing-section.html                           # NEW: Pricing card ($30/mo, unlimited employees)
│   │   ├── comparison-table.html                          # NEW: Base vs Premium comparison (Could Have F12)
│   │   ├── cta-section.html                               # NEW: CTA with role-based button
│   │   ├── trial-banner.html                              # NEW: Trial countdown banner partial (standard)
│   │   └── trial-banner-urgent.html                       # NEW: Urgent trial banner (≤7 days remaining)
│   └── admin-panel.html                                   # NEW: Admin premium status management

userfrosting/migrations/input/
├── 20260209_035_001_premium_columns.json                  # NEW: Add premium columns to stores
└── 20260209_035_002_premium_event_log.json                # NEW: Premium event tracking table

public_html/css/admin/modules/
└── premium.css                                            # NEW: Marketing page styles (if needed)
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Premium columns added to kiosk_buykiosk.stores table
Table: stores (MODIFY)
  ADD COLUMN: premiumStatus ENUM('none','trial','active','expired') NOT NULL DEFAULT 'none'
    COMMENT: 'Premium scheduling module status'
  ADD COLUMN: premiumTrialStartDate DATE DEFAULT NULL
    COMMENT: 'Date trial was activated'
  ADD COLUMN: premiumTrialEndDate DATE DEFAULT NULL
    COMMENT: 'Date trial expires (store timezone, end of day)'
  ADD COLUMN: premiumTrialUsed TINYINT(1) NOT NULL DEFAULT 0
    COMMENT: 'Flag indicating trial has been used (permanent)'
  ADD COLUMN: premiumActivatedAt DATETIME DEFAULT NULL
    COMMENT: 'Timestamp when premium was last activated'
  ADD COLUMN: premiumActivatedByUserId INT UNSIGNED DEFAULT NULL
    COMMENT: 'User who activated premium'
  ADD COLUMN: premiumDeactivatedAt DATETIME DEFAULT NULL
    COMMENT: 'Timestamp when premium was last deactivated'
  ADD INDEX: idx_premium_status (premiumStatus)
  ADD INDEX: idx_premium_trial_end (premiumTrialEndDate, premiumStatus)

# Premium event log for analytics tracking
# NOTE: This is the MVP analytics sink (PRD open question #3).
# Events are stored in a dedicated MySQL table for now.
# A proper analytics service (Mixpanel/Amplitude) can be added later
# by modifying PremiumEventLogger to dual-write.
Table: premiumEventLog (NEW) in kiosk_buykiosk
  id: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  typeNum: VARCHAR(10) NOT NULL
  event: VARCHAR(50) NOT NULL COMMENT 'Event name from PRD tracking requirements table'
  properties: JSON DEFAULT NULL COMMENT 'Event properties as JSON (see property mapping below)'
  userId: INT UNSIGNED DEFAULT NULL
  platform: ENUM('web','mobile','system') NOT NULL DEFAULT 'web'
  createdAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  INDEX: idx_typenum_event (typeNum, event)
  INDEX: idx_event_created (event, createdAt)

# Event → Property Mapping (maps to PRD Tracking Requirements table)
# Each event stores its required properties in the JSON `properties` column:
#
# premium_marketing_page_viewed:
#   { "storeId": int, "userRole": "owner|manager|member", "previousStatus": "none|expired" }
#
# premium_trial_started:
#   { "storeId": int, "trialEndDate": "YYYY-MM-DD", "previousProvider": "none|wiw|homebase", "source": "web|admin" }
#
# premium_trial_expired:
#   { "storeId": int, "shiftsCreated": int, "chatsSent": int, "aiSchedulesGenerated": int }
#
# premium_activated:
#   { "storeId": int, "fromTrial": bool, "previousProvider": "none|wiw|homebase", "source": "web|admin" }
#
# premium_deactivated:
#   { "storeId": int, "reason": "admin|support", "durationMonths": int, "source": "admin" }
#
# premium_feature_gated:
#   { "storeId": int, "feature": "scheduling|chat|ai_scheduling", "endpoint": "/api/...", "platform": "web|mobile" }
#
# premium_reactivated:
#   { "storeId": int, "gapDays": int, "source": "web|admin" }
#
# premium_cta_clicked:
#   { "storeId": int, "ctaType": "start_trial|enable_premium|reactivate", "pageLocation": "marketing|banner" }
```

#### Internal API Changes

```yaml
# Premium Trial/Activation API
Endpoint: Start Free Trial
  Method: POST
  Path: /api/:typeNum/premium/trial/start
  Auth: Session + checkStoreGroup + store owner permission
  Request: {} (no body needed)
  Response:
    success:
      success: true
      premiumStatus: "trial"
      trialEndDate: "2026-03-09"
      message: "Free trial activated! Enjoy premium scheduling for 1 month."
    error (already used trial):
      success: false
      error: "trial_already_used"
      message: "This store has already used its free trial."
    error (not owner):
      success: false
      error: "owner_required"
      message: "Only store owners can activate the free trial."

Endpoint: Activate Premium
  Method: POST
  Path: /api/:typeNum/premium/activate
  Auth: Session + checkStoreGroup + store owner permission
  Request: {} (no body needed)
  Response:
    success:
      success: true
      premiumStatus: "active"
      message: "Premium scheduling activated. $30/mo will be added to your BuyerKiosk bill."
    error (not owner):
      success: false
      error: "owner_required"

Endpoint: Get Premium Status
  Method: GET
  Path: /api/:typeNum/premium/status
  Auth: Session + checkStoreGroup
  Response:
    success:
      success: true
      premiumStatus: "trial" | "active" | "expired" | "none"
      trialEndDate: "2026-03-09" | null
      trialUsed: true | false
      daysRemaining: 14 | null  # only during trial
      isOwner: true | false

# Premium Gating Error Response (returned by middleware)
Endpoint: Any gated scheduling/chat/AI endpoint
  Response (403):
    success: false
    error: "premium_required"
    feature: "scheduling" | "chat" | "ai_scheduling"
    message: "This feature requires a Premium Scheduling subscription."
    upgradeUrl: "/admin/{typeNum}/schedule"

# Admin Premium Management API
Endpoint: Set Premium Status (Admin Only)
  Method: PUT
  Path: /admin/api/premium/:typeNum/status
  Auth: Session + admin permission (uri_store_settings)
  Request:
    premiumStatus: "active" | "none" | "expired"
  Response:
    success:
      success: true
      premiumStatus: "active"
      message: "Premium status updated."

Endpoint: Admin Start Trial (Admin Only)
  Method: POST
  Path: /admin/api/premium/:typeNum/trial/start
  Auth: Session + admin permission (uri_store_settings)
  Request:
    trialDurationDays: 30  # Optional, defaults to 1 calendar month
  Response:
    success:
      success: true
      premiumStatus: "trial"
      trialStartDate: "2026-02-09"
      trialEndDate: "2026-03-09"
      trialUsed: true
      message: "Trial started for store."
  Notes: |
    Admin can start a trial even if trialUsed=true (admin override).
    Sets premiumTrialUsed=1, premiumTrialStartDate, premiumTrialEndDate.
    Sets schedulingProvider='buyerkiosk'.
    Logs premium_trial_started event with source='admin'.

Endpoint: Admin Adjust Trial End Date (Admin Only)
  Method: PUT
  Path: /admin/api/premium/:typeNum/trial/extend
  Auth: Session + admin permission (uri_store_settings)
  Request:
    trialEndDate: "2026-04-09"  # New trial end date
  Response:
    success:
      success: true
      trialEndDate: "2026-04-09"
      message: "Trial end date updated."
  Notes: |
    Only works when premiumStatus='trial'.
    Logs premium_trial_extended event.

Endpoint: Admin Activate Premium (Admin Only)
  Method: POST
  Path: /admin/api/premium/:typeNum/activate
  Auth: Session + admin permission (uri_store_settings)
  Request: {} # no body needed
  Response:
    success:
      success: true
      premiumStatus: "active"
      message: "Premium activated for store."
  Notes: |
    Sets premiumStatus='active', premiumActivatedAt=NOW().
    Sets schedulingProvider='buyerkiosk' if not already set.
    Logs premium_activated event with source='admin'.

Endpoint: Admin Deactivate Premium (Admin Only)
  Method: POST
  Path: /admin/api/premium/:typeNum/deactivate
  Auth: Session + admin permission (uri_store_settings)
  Request: {} # no body needed
  Response:
    success:
      success: true
      premiumStatus: "expired"
      message: "Premium deactivated for store."
  Notes: |
    Sets premiumStatus='expired', premiumDeactivatedAt=NOW().
    Sets schedulingProvider='none'.
    Logs premium_deactivated event with source='admin'.
    Invalidates Redis cache.

# Mobile API Additions
Endpoint: Store Info (MODIFIED)
  Path: POST /api/mobile/verify (existing)
  Response additions to each store object:
    premiumScheduling:
      status: "none" | "trial" | "active" | "expired"
      trialEndDate: "2026-03-09" | null
      features:
        scheduling: true | false
        chat: true | false
        aiScheduling: true | false
```

#### Application Data Models

```pseudocode
ENTITY: PremiumStatus (NEW - Value Object)
  CONSTANTS:
    NONE = 'none'
    TRIAL = 'trial'
    ACTIVE = 'active'
    EXPIRED = 'expired'

  BEHAVIORS:
    isActive(): bool  # Returns true for TRIAL or ACTIVE
    isTrial(): bool
    canStartTrial(trialUsed: bool): bool
    label(): string   # Human-readable label

ENTITY: Store (MODIFIED)
  FIELDS:
    + premiumStatus: string (NEW) - default 'none'
    + premiumTrialStartDate: ?string (NEW)
    + premiumTrialEndDate: ?string (NEW)
    + premiumTrialUsed: bool (NEW) - default false
    + premiumActivatedAt: ?string (NEW)
    + premiumActivatedByUserId: ?int (NEW)

  BEHAVIORS:
    + getPremiumStatus(): PremiumStatus (NEW)
    + isPremiumActive(): bool (NEW) - returns true if trial or active
    + getPremiumTrialEndDate(): ?string (NEW)
    + isPremiumTrialUsed(): bool (NEW)

ENTITY: PremiumService (NEW - Service)
  DEPENDENCIES: PDO $buykioskDb, Redis $redis

  BEHAVIORS:
    isPremiumActive(typeNum: string): bool  # Cached check
    getPremiumInfo(typeNum: string): array  # Full status info
    invalidateCache(typeNum: string): void  # Clear Redis cache

  CACHE:
    key: "premium:{typeNum}"
    ttl: 60 seconds
    value: JSON {status, trialEndDate, trialUsed}

ENTITY: TrialService (NEW - Service)
  DEPENDENCIES: PDO $buykioskDb, PremiumService, PremiumEventLogger, TrialUsageService

  BEHAVIORS:
    startTrial(typeNum: string, userId: int, storeTimezone: string): array
    activatePremium(typeNum: string, userId: int): array
    deactivatePremium(typeNum: string, userId: int): array
    processExpiredTrials(): int  # Returns count of expired trials
    getDaysRemaining(typeNum: string, storeTimezone: string): ?int

ENTITY: TrialUsageService (NEW - Service)
  DEPENDENCIES: PDO $storeDb, PDO $buykioskDb

  BEHAVIORS:
    getTrialUsageSummary(typeNum: string, trialStartDate: string): array
    # Returns: { shiftsCreated: int, chatsSent: int, aiSchedulesGenerated: int }

  DATA SOURCES:
    # Shifts created during trial:
    #   SELECT COUNT(*) FROM scheduleShifts
    #   WHERE createdAt >= :trialStartDate
    #   (store database, kiosk_{typeNum})
    #
    # Chats sent during trial:
    #   SELECT COUNT(*) FROM chatMessages
    #   WHERE createdAt >= :trialStartDate
    #   (store database, kiosk_{typeNum})
    #
    # AI schedules generated during trial:
    #   SELECT COUNT(*) FROM aiScheduleJobs
    #   WHERE createdAt >= :trialStartDate AND status = 'completed'
    #   (store database, kiosk_{typeNum})

  USAGE:
    # 1. Trial Status Dashboard (Feature 9): Called by SchedulingPageController
    #    to render usage stats in the trial banner/card.
    # 2. Trial Expiration Event: Called by TrialExpirationJob to include
    #    usage stats in the premium_trial_expired event properties.

ENTITY: TrialExpirationJob (NEW - TaskEngine Job)
  SCOPE: global
  QUEUE: default
  SCHEDULE: daily at 00:15 UTC
  TIMEOUT: 300 seconds

  BEHAVIORS:
    handle(): JobResult
    # Queries stores WHERE premiumStatus='trial' AND premiumTrialEndDate < NOW()
    # For each: sets premiumStatus='expired', logs event, invalidates cache
```

#### Chat & AI UI Gating Points

The following specific UI elements must be hidden when premium is not active:

```yaml
# Chat UI Gating
chat_gating:
  - component: "Sidebar chat navigation item"
    file: "templates/themes/default/menus/sidebar.html"
    gating: "{% if isPremiumActive(store.typeNum) %} around chat nav link"
    behavior: "Completely hidden — no locked icon, no placeholder"

  - component: "Chat panel/tab on scheduling page"
    file: "templates/themes/default/scheduling/calendar.html"
    gating: "{% if isPremiumActive(store.typeNum) %} around chat panel include"
    behavior: "Chat panel not rendered at all. No empty space."

  - component: "Chat page route"
    file: "routes/admin/chat.php"
    gating: "PremiumGateMiddleware::requirePremium('chat') on route group"
    behavior: "Page controller checks premium; renders marketing page if inactive"

# AI Scheduling UI Gating
ai_gating:
  - component: "AI Schedule Generate button on calendar toolbar"
    file: "templates/themes/default/scheduling/calendar.html"
    gating: "{% if isPremiumActive(store.typeNum) %} around AI button"
    behavior: "Button hidden entirely when not premium"

  - component: "AI scheduling settings section"
    file: "templates/themes/default/scheduling/settings.html"
    gating: "{% if isPremiumActive(store.typeNum) %} around AI config section"
    behavior: "AI settings section hidden when not premium"

  - component: "AI schedule sidebar panel"
    file: "templates/themes/default/scheduling/partials/ai-panel.html"
    gating: "{% if isPremiumActive(store.typeNum) %} around entire partial"
    behavior: "AI panel not included in DOM when not premium"
```

#### Integration Points

```yaml
# Web UI Integration
- from: SchedulingPageController
  to: PremiumService
  protocol: PHP method call
  data_flow: "Check premium status before rendering schedule page"
  action: "If not active, render marketing.html instead of calendar.html"

- from: Sidebar Template (Twig)
  to: PremiumTwigExtension
  protocol: Twig function call
  data_flow: "isPremiumActive(store.typeNum) controls nav item visibility for scheduling, chat, and AI features"

# API Middleware Integration
- from: Scheduling/Chat/AI Routes
  to: PremiumGateMiddleware
  protocol: Slim middleware callable
  data_flow: "Checks premium before route handler executes, returns 403 if inactive"

# Mobile API Integration
- from: MobileApiController
  to: PremiumService
  protocol: PHP method call
  data_flow: "Adds premiumScheduling object to store info response"

# TaskEngine Integration
- from: TaskEngine Scheduler
  to: TrialExpirationJob
  protocol: Redis queue dispatch
  data_flow: "Daily job processes expired trials across all stores"
```

### Implementation Examples

#### Example: Premium Gate Middleware

**Why this example**: Shows how the middleware intercepts requests and returns consistent 403 responses, which is the core gating mechanism.

```php
// PremiumGateMiddleware.php
class PremiumGateMiddleware
{
    /**
     * Create middleware callable for a specific feature
     *
     * @param string $feature 'scheduling'|'chat'|'ai_scheduling'
     * @return callable Slim middleware
     */
    public static function requirePremium(string $feature): callable
    {
        return function () use ($feature) {
            $app = \Slim\Slim::getInstance();
            $route = $app->router()->getCurrentRoute();
            $params = $route->getParams();
            $typeNum = $params['typeNum'] ?? null;

            if (!$typeNum) return; // Let route handle missing typeNum

            $premiumService = new PremiumService(
                dbConnectByName($_ENV['DB_NAME'] ?? 'kiosk_buykiosk')
            );

            if (!$premiumService->isPremiumActive($typeNum)) {
                $app->response->setStatus(403);
                $app->response->headers->set('Content-Type', 'application/json');
                $app->response->setBody(json_encode([
                    'success' => false,
                    'error' => 'premium_required',
                    'feature' => $feature,
                    'message' => 'This feature requires a Premium Scheduling subscription.',
                ]));
                $app->stop();
            }
        };
    }
}
```

#### Example: PremiumService with Redis Caching

**Why this example**: Shows the caching strategy that ensures <60 second propagation while avoiding database queries on every request.

```php
// PremiumService.php
class PremiumService
{
    private const CACHE_PREFIX = 'premium:';
    private const CACHE_TTL = 60; // seconds

    public function isPremiumActive(string $typeNum): bool
    {
        $info = $this->getPremiumInfo($typeNum);
        $status = $info['status'];

        // Runtime trial expiration enforcement (Codex review fix):
        // Even if the daily TrialExpirationJob hasn't run yet, we enforce
        // expiration at read-time to satisfy the PRD "on the day" requirement.
        if ($status === 'trial' && !empty($info['trialEndDate']) && !empty($info['timezone'])) {
            if ($this->isTrialExpiredInStoreTimezone($info['trialEndDate'], $info['timezone'])) {
                return false; // Treat as expired at read-time
            }
        }

        return in_array($status, ['trial', 'active'], true);
    }

    /**
     * Check if a trial has expired in the store's local timezone.
     * Trial ends at 23:59:59 on the trialEndDate in the store's timezone.
     */
    private function isTrialExpiredInStoreTimezone(string $trialEndDate, string $timezone): bool
    {
        $tz = new \DateTimeZone($timezone);
        $now = new \DateTime('now', $tz);
        $endDate = new \DateTime($trialEndDate . ' 23:59:59', $tz);
        return $now > $endDate;
    }

    public function getPremiumInfo(string $typeNum): array
    {
        // Try Redis cache first
        $cached = $this->getFromCache($typeNum);
        if ($cached !== null) {
            return $cached;
        }

        // Query database — include timezone for runtime expiration check
        $stmt = $this->db->prepare("
            SELECT premiumStatus, premiumTrialEndDate, premiumTrialUsed, timezone
            FROM stores
            WHERE typeNum = :typeNum
        ");
        $stmt->execute([':typeNum' => $typeNum]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        $info = [
            'status' => $row['premiumStatus'] ?? 'none',
            'trialEndDate' => $row['premiumTrialEndDate'] ?? null,
            'trialUsed' => (bool)($row['premiumTrialUsed'] ?? false),
            'timezone' => $row['timezone'] ?? 'America/New_York',
        ];

        // Cache for 60 seconds
        $this->setCache($typeNum, $info);

        return $info;
    }

    public function invalidateCache(string $typeNum): void
    {
        $redis = $this->getRedis();
        if ($redis) {
            $redis->del(self::CACHE_PREFIX . $typeNum);
        }
    }
}
```

#### Example: Trial Expiration Job

**Why this example**: Demonstrates the TaskEngine job pattern for daily trial processing with timezone awareness.

```php
// TrialExpirationJob.php
class TrialExpirationJob extends BaseJob
{
    public static function getName(): string { return 'trial-expiration'; }
    public static function getDisplayName(): string { return 'Premium Trial Expiration'; }
    public static function getQueue(): string { return 'default'; }
    public static function getScope(): string { return 'global'; }
    public static function getTimeout(): int { return 300; }

    public function handle(): JobResult
    {
        $this->info('Checking for expired premium trials');

        $db = dbConnectByName($_ENV['DB_NAME'] ?? 'kiosk_buykiosk');

        // Find all trial stores where trial has ended
        // We check against UTC date and handle timezone in PHP
        $stmt = $db->prepare("
            SELECT typeNum, premiumTrialEndDate, timezone
            FROM stores
            WHERE premiumStatus = 'trial'
              AND premiumTrialEndDate IS NOT NULL
        ");
        $stmt->execute();
        $trials = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        $expired = 0;
        foreach ($trials as $trial) {
            $tz = new \DateTimeZone($trial['timezone'] ?? 'America/New_York');
            $now = new \DateTime('now', $tz);
            $endDate = new \DateTime($trial['premiumTrialEndDate'] . ' 23:59:59', $tz);

            if ($now > $endDate) {
                // Trial has expired in store's timezone
                $trialService = new TrialService($db);
                $trialService->expireTrial($trial['typeNum']);
                $expired++;
                $this->info("Expired trial for store: {$trial['typeNum']}");
            }
        }

        return JobResult::success(['expired_count' => $expired]);
    }
}
```

## Runtime View

### Primary Flow: Non-Premium User Navigates to Schedule

1. User clicks "Schedule" in sidebar navigation
2. Browser navigates to `/admin/:typeNum/schedule`
3. `SchedulingPageController::renderCalendar()` is called
4. Controller calls `PremiumService::isPremiumActive($typeNum)`
5. Service checks Redis cache → miss → queries `stores.premiumStatus`
6. Returns `false` (status is `none`)
7. Controller renders `premium/marketing.html` instead of `scheduling/calendar.html`
8. User sees marketing page with feature showcase and CTA

```mermaid
sequenceDiagram
    actor Owner as Store Owner
    participant Sidebar as Sidebar Nav
    participant PageCtrl as SchedulingPageController
    participant PremSvc as PremiumService
    participant Redis as Redis Cache
    participant DB as stores table
    participant Twig as Twig Renderer

    Owner->>Sidebar: Clicks "Schedule"
    Sidebar->>PageCtrl: GET /admin/:typeNum/schedule
    PageCtrl->>PremSvc: isPremiumActive(typeNum)
    PremSvc->>Redis: GET premium:{typeNum}
    Redis-->>PremSvc: null (cache miss)
    PremSvc->>DB: SELECT premiumStatus FROM stores
    DB-->>PremSvc: {status: 'none'}
    PremSvc->>Redis: SET premium:{typeNum} (60s TTL)
    PremSvc-->>PageCtrl: false
    PageCtrl->>Twig: render('premium/marketing.html')
    Twig-->>Owner: Marketing page with CTA
```

### Secondary Flow: Trial Activation

1. Store owner clicks "Start Free Trial" on marketing page
2. JavaScript sends POST to `/api/:typeNum/premium/trial/start`
3. `PremiumApiController::startTrial()` validates owner permission
4. `TrialService::startTrial()` checks `trialUsed` flag
5. Sets `premiumStatus='trial'`, `premiumTrialEndDate=+1 month`, `trialUsed=1`
6. Sets `schedulingProvider='buyerkiosk'`
7. Invalidates Redis cache
8. Logs `premium_trial_started` event
9. Returns success → page refreshes to full scheduling interface

```mermaid
sequenceDiagram
    actor Owner as Store Owner
    participant JS as Frontend JS
    participant API as PremiumApiController
    participant Trial as TrialService
    participant DB as stores table
    participant Redis as Redis Cache
    participant Logger as PremiumEventLogger

    Owner->>JS: Clicks "Start Free Trial"
    JS->>JS: Show confirmation dialog
    Owner->>JS: Confirms
    JS->>API: POST /api/:typeNum/premium/trial/start
    API->>API: Check owner permission
    API->>Trial: startTrial(typeNum, userId, timezone)
    Trial->>DB: SELECT premiumTrialUsed FROM stores
    DB-->>Trial: trialUsed = 0
    Trial->>DB: UPDATE stores SET premiumStatus='trial', ...
    Trial->>DB: UPDATE stores SET schedulingProvider='buyerkiosk'
    Trial->>Redis: DEL premium:{typeNum}
    Trial->>Logger: log('premium_trial_started', ...)
    Trial-->>API: {success: true, trialEndDate: '2026-03-09'}
    API-->>JS: JSON response
    JS->>JS: window.location.reload()
    Note over Owner: Page reloads → calendar view
```

### Tertiary Flow: Mobile API Premium Check

1. Mobile app calls `POST /api/mobile/verify` for login
2. `MobileApiController` loads store info including premium fields
3. App receives `premiumScheduling.status` for each store
4. App adjusts UI based on status (hide/show scheduling and chat)
5. If app tries to call a gated endpoint while not premium, receives 403

### Marketing Page Content Structure

The marketing page (`premium/marketing.html`) maps to PRD Feature 2 content requirements:

```yaml
marketing_page_sections:
  - section: "Hero"
    partial: "hero-section.html"
    content:
      headline: "Premium Scheduling for Your Team"
      subheadline: "Create schedules, chat with your team, and let AI do the heavy lifting — all in one place."
      background: Gradient using design tokens (var(--primary-50) to var(--white))

  - section: "Feature Grid"
    partial: "feature-grid.html"
    content:
      layout: "3-column responsive grid (Bootstrap row/col-md-4)"
      cards:
        - title: "Smart Scheduling"
          icon: "fa-calendar-check"
          screenshot: "premium/screenshots/schedule-calendar.png"
          description: "Drag-and-drop shift creation, templates, overtime tracking, and conflict detection."
        - title: "Team Chat"
          icon: "fa-comments"
          screenshot: "premium/screenshots/team-chat.png"
          description: "Built-in team messaging with channels, direct messages, and shift-specific conversations."
        - title: "AI Schedule Builder"
          icon: "fa-robot"
          screenshot: "premium/screenshots/ai-scheduling.png"
          description: "Let AI generate optimized schedules based on availability, roles, and labor targets."
        - title: "Mobile Access"
          icon: "fa-mobile-screen"
          screenshot: "premium/screenshots/mobile-app.png"
          description: "Team members view schedules, swap shifts, and chat from the BuyerKiosk Team app."

  - section: "Pricing"
    partial: "pricing-section.html"
    content:
      price: "$30/month"
      tagline: "Unlimited employees. One flat rate."
      comparison_note: "Compare to $4-8/user/month with competitors"
      includes_list:
        - "Weekly & monthly schedule views"
        - "Shift templates & AI generation"
        - "Team chat with channels"
        - "Timesheet management"
        - "Overtime & labor cost tracking"
        - "Mobile app access for all team members"

  - section: "CTA"
    partial: "cta-section.html"
    content:
      owner_view:
        primary_cta: "Start Free Trial (1 Month)"  # If trialUsed=false
        secondary_cta: "Enable Premium ($30/mo)"    # If trialUsed=true or expired
        reactivate_cta: "Reactivate Premium"        # If status=expired
      non_owner_view:
        message: "Ask your store owner to enable Premium Scheduling."
        note: "Only store owners can activate premium features."
      wiw_active_note: "Note: Enabling premium will replace your current When I Work integration."
```

**Screenshot assets**: Static PNG screenshots (not live demos per PRD decision). Stored in `public_html/img/premium/screenshots/`. Created during implementation from staging environment.

### Trial Banner Display Logic

The scheduling page controller renders a trial banner when `premiumStatus='trial'`. The banner has two variants:

```pseudocode
FUNCTION: getTrialBannerVariant(daysRemaining)
  IF daysRemaining > 7:
    RETURN 'standard'
    # Light info banner: "You're on a free trial. X days remaining."
    # Uses: var(--info-100) background, var(--info-700) text
    # Dismissible per session (sessionStorage flag)

  IF daysRemaining <= 7 AND daysRemaining > 1:
    RETURN 'urgent'
    # Prominent warning banner: "Your free trial ends in X days! Subscribe now to keep your scheduling features."
    # Uses: var(--warning-100) background, var(--warning-700) text
    # NOT dismissible. Shows on every page load.
    # Includes "Subscribe Now" CTA button linking to premium activation

  IF daysRemaining <= 1:
    RETURN 'urgent'
    # Critical banner: "Your free trial ends today! Subscribe now or you'll lose access."
    # Uses: var(--danger-100) background, var(--danger-700) text
    # NOT dismissible. Shows on every page load.
    # Includes "Subscribe Now" CTA button
```

The `daysRemaining` value is calculated by `PremiumService::getDaysRemaining()` using the store timezone. The banner partial is included in the scheduling page layout (calendar, settings, timesheets, etc.) when the store is on trial.

### Background Flow: Trial Expiration

1. TaskEngine scheduler triggers `TrialExpirationJob` daily at 00:15 UTC
2. Job queries all stores with `premiumStatus='trial'`
3. For each store, compares `premiumTrialEndDate` against current date in store timezone
4. If expired: updates status to `expired`, invalidates cache, logs event
5. Next time any user/app checks premium status, they see `expired`

### Error Handling

- **Invalid permission** (non-owner tries to start trial): 403 with `owner_required` error code. User sees "Ask your store owner to enable Premium Scheduling."
- **Trial already used**: 400 with `trial_already_used`. CTA shows "Enable Premium ($30/mo)" instead of trial button.
- **Concurrent trial activation**: Database-level check on `premiumTrialUsed` flag. First UPDATE wins, second request gets `trial_already_used`.
- **Redis unavailable**: `PremiumService` falls back to direct database query. Performance degrades but functionality is preserved.
- **TaskEngine not running**: Trials don't auto-expire. Admin can manually expire via admin panel. Warning logged.
- **Network failure on mobile**: App caches last-known premium status locally. Stale data resolves on next successful API call.

## Deployment View

### Single Application Deployment
- **Environment**: Existing PHP application on web server. No new services needed.
- **Configuration**: No new environment variables required. Premium status is entirely database-driven.
- **Dependencies**: Existing Redis instance (for caching). Existing TaskEngine workers (for trial expiration).
- **Performance**:
  - Premium check: <1ms (Redis hit) or <5ms (DB query) — negligible per-request overhead
  - Cache TTL of 60s means max 60s propagation delay for status changes
  - Trial expiration job: <30s for processing all stores

### Deployment Sequence
1. Run migration: `php userfrosting/conductor run` (adds columns, creates event log table)
2. Deploy PHP code (new classes, modified routes/controllers)
3. Build CSS: `php userfrosting/conductor build-css --minify` (if marketing page CSS added)
4. Register job: Automatic via `TaskCommandFactory::registerJobs()`
5. Verify: `php userfrosting/bin/task job:list` shows `trial-expiration`

### Rollback Strategy
- Remove premium middleware from routes → all features accessible again
- Premium columns remain in database but are ignored
- No data loss risk — rolling back only removes the gating, not any data

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
- pattern: Premium gating via middleware + service
  relevance: CRITICAL
  why: "New pattern for database-driven per-store feature gating (vs env var flags)"

- pattern: Redis-cached database lookups with TTL
  relevance: HIGH
  why: "Reusable pattern for any per-store config that needs fast reads"
```

### System-Wide Patterns

- **Security**: Premium gating adds authorization, not authentication. Auth is handled by existing session/JWT middleware. Premium middleware runs AFTER auth.
- **Error Handling**: Consistent 403 JSON response with `error: "premium_required"` and `feature` field. Same format for web and mobile APIs.
- **Performance**: Redis cache prevents database queries on every request. 60s TTL is a balance between freshness and performance.
- **Logging/Auditing**: `premiumEventLog` table captures all premium state transitions for analytics. Events follow the tracking requirements in the PRD.

### Implementation Patterns

#### Code Patterns and Conventions
- **Namespace**: `BuyerKiosk\Premium\*` for all new classes
- **Controllers**: Follow existing pattern — constructor takes `$app` and `$store`, lazy-load dependencies
- **Repositories**: Direct PDO queries (no ORM), prepared statements, camelCase column names
- **Error responses**: `json_encode(['success' => false, 'error' => 'code', 'message' => 'Human text'])`

#### State Management Patterns
- Premium status is server-side state only (database + Redis cache)
- No client-side state management needed — page renders reflect current status
- Mobile apps cache premium status from store info API response
- Cache invalidation on every status change ensures consistency

#### Performance Characteristics
- Redis GET: ~0.2ms per premium check (cached path)
- Database SELECT: ~2-5ms per premium check (uncached path)
- Marketing page render: ~50ms (simple Twig template, no heavy queries)
- Trial expiration job: O(n) where n = number of trial stores (expected <100)

#### Integration Patterns
- Premium middleware is a Slim middleware callable, same pattern as `HybridAuthMiddleware`
- Twig extension follows existing extension registration pattern
- TaskEngine job extends `BaseJob`, same as `AiScheduleCleanupJob`

#### Component Structure Pattern
```pseudocode
COMPONENT: PremiumGate(store)
  CHECK: PremiumService.isPremiumActive(store.typeNum)

  IF active: PROCEED to original controller/route
  IF not active:
    IF web page request: RENDER marketing.html
    IF API request: RETURN 403 JSON
    IF mobile API: RETURN 403 JSON with feature name
```

#### Error Handling Pattern
```pseudocode
FUNCTION: handlePremiumError(request, feature)
  CLASSIFY:
    - not_premium → 403 with upgrade URL
    - trial_already_used → 400 with activate prompt
    - owner_required → 403 with "ask owner" message
    - concurrent_activation → 409 with "already active" message
  LOG: event to premiumEventLog
  RESPOND: JSON with error code and human message
```

#### Test Pattern
```pseudocode
TEST_SCENARIO: "Premium gating blocks scheduling API when not premium"
  SETUP: Store with premiumStatus='none'
  EXECUTE: GET /api/:typeNum/schedule/shifts
  VERIFY:
    - HTTP 403 returned
    - Response contains error='premium_required'
    - Response contains feature='scheduling'
    - No schedule data leaked

TEST_SCENARIO: "Trial activation sets correct dates and provider"
  SETUP: Store with premiumStatus='none', premiumTrialUsed=0
  EXECUTE: POST /api/:typeNum/premium/trial/start
  VERIFY:
    - premiumStatus changed to 'trial'
    - premiumTrialEndDate = today + 1 calendar month
    - premiumTrialUsed = 1
    - schedulingProvider changed to 'buyerkiosk'
    - Redis cache invalidated
    - premium_trial_started event logged
```

### Integration Points

- **Connection Points**: Premium middleware hooks into existing Slim route groups. Twig extension registers globally. TaskEngine job registers in `TaskCommandFactory`.
- **Data Flow**: Premium status flows from `stores` table → `PremiumService` (cached) → middleware/controllers/templates. Status changes flow through `TrialService` → database → cache invalidation.
- **Events**: `premium_trial_started`, `premium_activated`, `premium_deactivated`, `premium_trial_expired`, `premium_marketing_page_viewed`, `premium_feature_gated`, `premium_cta_clicked`, `premium_reactivated` — all logged to `premiumEventLog`.

## Architecture Decisions

- [x] ADR-1 **Premium status on stores table (not separate table)**: Store premium columns directly on `kiosk_buykiosk.stores`
  - Rationale: `schedulingProvider` already lives here. One row per store, simple JOINs, consistent with existing patterns. A separate `storePremium` table adds complexity for no benefit given the small number of columns (7).
  - Trade-offs: Stores table gets wider (7 new columns). If premium grows significantly, may need to refactor. Acceptable for MVP.
  - User confirmed: **Yes** (2026-02-09)

- [x] ADR-2 **Redis cache with 60s TTL for premium status**: Cache premium status in Redis with 60-second TTL
  - Rationale: PRD requires "within 60 seconds" propagation. 60s TTL naturally expires. Active invalidation on status change provides immediate propagation when possible. Fallback to DB query if Redis is down.
  - Trade-offs: 60s of stale data is possible. A user could access premium features for up to 60s after expiration. Acceptable per PRD requirements.
  - User confirmed: **Yes** (2026-02-09)

- [x] ADR-3 **Middleware + page controller intercept (not route replacement)**: Use middleware for API gating and controller-level check for page rendering
  - Rationale: Middleware is the cleanest pattern for API endpoints (consistent 403). Page controllers need to render the marketing page (not just 403), so they need controller-level logic. This two-pronged approach handles both cases cleanly.
  - Trade-offs: Two gating mechanisms (middleware for APIs, controller for pages). Could cause confusion. Mitigated by clear documentation and the PremiumService being the single source of truth.
  - User confirmed: **Yes** (2026-02-09)

- [x] ADR-4 **Twig extension for sidebar gating**: Register `isPremiumActive()` as a Twig function
  - Rationale: The sidebar template already uses `checkAccess()` Twig functions for permission gating. Adding `isPremiumActive()` follows the same pattern. Alternative was to pass a variable from every page controller, but that's error-prone and repetitive.
  - Trade-offs: Twig extension makes a service call (cached) on every page render that has the sidebar. Acceptable since Redis GET is <1ms.
  - User confirmed: **Yes** (2026-02-09)

- [x] ADR-5 **TaskEngine job for trial expiration with runtime enforcement**: Daily batch job processes expired trials AND runtime read-time enforcement
  - Rationale: Consistent with existing TaskEngine patterns (AI cleanup, chat retention). PRD says "daily scheduled job" explicitly. However, to satisfy the PRD "on the day" requirement, `PremiumService::isPremiumActive()` also performs a real-time timezone-aware check at read-time. The daily job serves as a cleanup mechanism that writes the `expired` status to the database, while the runtime check ensures users see the correct behavior immediately on the expiration day.
  - Trade-offs: Dual enforcement means the runtime check is slightly more complex (timezone calculation on each cache miss). Acceptable since this only triggers for `trial` status stores and the overhead is negligible (~1ms).
  - User confirmed: **Yes** (2026-02-09)

- [x] ADR-6 **Marketing page as a Twig template (not SPA)**: Static marketing page rendered server-side
  - Rationale: PRD says "static screenshots, not live demo." A Twig template is simpler, faster, and doesn't need JavaScript bundling. Consistent with existing admin pages. The trial activation CTA uses a simple AJAX POST + page reload.
  - Trade-offs: Less interactive than an SPA. Acceptable for a marketing/upsell page. Can be enhanced later.
  - User confirmed: **Yes** (2026-02-09)

## Quality Requirements

- **Performance**:
  - Premium status check: <5ms per request (cached path <1ms)
  - Marketing page load: <2 seconds on standard broadband
  - Trial activation: <1 second response time
  - Status propagation: <60 seconds after any change

- **Usability**:
  - Marketing page responsive on tablet (768px+) and desktop (1024px+)
  - Clear visual distinction between trial/active/expired states
  - Non-owner users see appropriate messaging (no confusing disabled buttons)
  - Mobile apps degrade gracefully when premium status changes

- **Security**:
  - Only store owners can activate trial/premium (permission check)
  - Admin-only override via admin panel (separate permission)
  - Premium middleware cannot be bypassed by direct URL access
  - No premium data leaks in 403 responses (no schedule data in error responses)
  - Trial activation is idempotent (double-click safe)

- **Reliability**:
  - Redis failure falls back to database (no service disruption)
  - TaskEngine failure means manual admin intervention for trial expiration
  - Data preserved on expiration (no data loss)
  - Concurrent activation handled at database level (first write wins)

## Risks and Technical Debt

### Known Technical Issues
- Feature flag system is currently env-var-only (`Store::getFeatureFlag()`). Premium introduces the first database-driven per-store flag. The existing `getFeatureFlag()` TODO for JSON column support remains separate.
- `schedulingProvider` is set to `'buyerkiosk'` during trial activation. If premium expires, it reverts to `'none'` — NOT back to the previous provider (WIW/Homebase). This is per PRD but may surprise users.

### Technical Debt
- The marketing page is a one-off Twig template. If BuyerKiosk adds more premium modules in the future, a CMS-manageable marketing page system would be better.
- Premium event logging goes to a custom table. A proper analytics service (Mixpanel, Amplitude) would be more appropriate long-term. The `PremiumEventLogger` is designed to be the single write point so adding a dual-write to an analytics service is a one-file change.
- Admin premium management is basic (set status only). A full admin panel with analytics dashboard is a "Should Have" feature.
- Billing integration is manual (email notification to operations). Automating the billing line-item addition via API integration with the billing system is future work.
- Trial usage stats are computed with live COUNT queries. If performance becomes an issue at scale, consider a materialized summary table or periodic aggregation job.

### Implementation Gotchas
- **PDO named param reuse**: Cannot reuse `:typeNum` in the same query twice. Use unique names (`:typeNum1`, `:typeNum2`). See MEMORY.md.
- **DateTime::modify('+1 month')**: PHP handles month-end edge cases correctly (Jan 31 → Feb 28). This is the correct approach for trial end date calculation.
- **Twig extension registration**: Must be registered in the app bootstrap, not in route files. Check how existing Twig extensions are registered.
- **Slim 2 middleware ordering**: Middleware runs in LIFO order. Premium middleware must be added AFTER auth middleware so auth runs first.
- **Cache key collisions**: Use `premium:` prefix for Redis keys to avoid collisions with other cached data.
- **Store timezone**: `$store->timezone` may be null for some stores. Default to `'America/New_York'` if not set.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Premium Gating Blocks Scheduling API**
```gherkin
Given: Store "pc00" has premiumStatus = "none"
And: User has uri_schedule permission
When: User requests GET /api/pc00/schedule/shifts
Then: Response status is 403
And: Response body contains error = "premium_required"
And: Response body contains feature = "scheduling"
And: No schedule data is returned
```

**Scenario 2: Trial Activation Happy Path**
```gherkin
Given: Store "pc00" has premiumStatus = "none" and premiumTrialUsed = 0
And: User is store owner with appropriate permissions
When: User sends POST /api/pc00/premium/trial/start
Then: Response status is 200
And: premiumStatus is changed to "trial"
And: premiumTrialEndDate is set to 1 calendar month from today
And: premiumTrialUsed is set to 1
And: schedulingProvider is set to "buyerkiosk"
And: Redis cache for "premium:pc00" is invalidated
And: "premium_trial_started" event is logged
```

**Scenario 3: Trial Already Used**
```gherkin
Given: Store "pc00" has premiumStatus = "expired" and premiumTrialUsed = 1
When: User sends POST /api/pc00/premium/trial/start
Then: Response status is 400
And: Response body contains error = "trial_already_used"
And: premiumStatus remains "expired"
```

**Scenario 4: Trial Expiration Job**
```gherkin
Given: Store "pc00" has premiumStatus = "trial" and premiumTrialEndDate = yesterday
And: Store timezone is "America/New_York"
When: TrialExpirationJob runs
Then: premiumStatus is changed to "expired"
And: schedulingProvider is changed to "none"
And: "premium_trial_expired" event is logged with usage stats
And: Redis cache is invalidated
```

**Scenario 5: Chat Feature Gating**
```gherkin
Given: Store "pc00" has premiumStatus = "none"
When: User requests POST /api/pc00/staff-chat/channels
Then: Response status is 403
And: Response body contains feature = "chat"
And: Chat data is not returned
```

**Scenario 6: Mobile API Premium Status**
```gherkin
Given: Store "pc00" has premiumStatus = "trial" and trialEndDate = "2026-03-09"
When: Mobile app calls POST /api/mobile/verify
Then: Store info response includes premiumScheduling object
And: premiumScheduling.status = "trial"
And: premiumScheduling.trialEndDate = "2026-03-09"
And: premiumScheduling.features.scheduling = true
And: premiumScheduling.features.chat = true
```

**Scenario 7: Non-Owner Cannot Activate**
```gherkin
Given: Store "pc00" has premiumStatus = "none"
And: User is a store manager (not owner)
When: User sends POST /api/pc00/premium/trial/start
Then: Response status is 403
And: Response body contains error = "owner_required"
```

**Scenario 8: Premium Active Allows Access**
```gherkin
Given: Store "pc00" has premiumStatus = "active"
And: User has uri_schedule permission
When: User requests GET /api/pc00/schedule/shifts?start=2026-02-01&end=2026-02-07
Then: Response status is 200
And: Schedule data is returned normally
```

### Test Coverage Requirements

- **Business Logic**: All premium status transitions (none→trial, none→active, trial→expired, expired→active, active→none). Trial date calculation edge cases (month-end). Permission checks for all user roles.
- **API Endpoints**: All new premium API endpoints. All gated scheduling/chat/AI endpoints returning 403 when not premium. Mobile API store info with premium fields.
- **Middleware**: Premium middleware blocks correctly for each feature type. Middleware passes through when premium is active. Middleware handles missing typeNum gracefully.
- **Cache**: Redis cache hit returns correct data. Cache miss queries database and sets cache. Cache invalidation works on status change. Redis failure falls back to database.
- **TaskEngine Job**: Job finds and expires correct trials. Timezone handling is correct. Job is idempotent (running twice doesn't cause errors). Job handles empty result set (no trials to expire).
- **UI**: Marketing page renders for non-premium stores. Calendar renders for premium stores. Sidebar hides scheduling when not premium. Standard trial banner shows when >7 days remaining. Urgent trial banner shows when <=7 days remaining. Chat panel/tab hidden when not premium. AI buttons hidden when not premium.
- **Trial Usage**: TrialUsageService correctly counts shifts, chats, and AI schedules created during trial period. Usage summary is included in trial dashboard and expiration event.

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Premium Status | One of: `none`, `trial`, `active`, `expired`. Determines access to scheduling, chat, and AI features | Stored per-store in `stores.premiumStatus` |
| Trial | 1-calendar-month free period where all premium features are available. Each store gets exactly one trial | Tracked by `premiumTrialUsed` flag |
| Premium Gating | The mechanism that blocks or allows access to features based on premium status | Implemented via middleware (APIs) and controller checks (pages) |
| Marketing Page | The upsell/feature-showcase page shown to non-premium stores when they navigate to scheduling | Rendered by `PremiumPageController` |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| PremiumService | Singleton-like service that checks premium status with Redis caching | Central source of truth for all premium checks |
| PremiumGateMiddleware | Slim middleware callable that returns 403 for non-premium API requests | Applied to scheduling, chat, and AI route groups |
| TrialExpirationJob | TaskEngine job that runs daily to transition expired trials to `expired` status | Registered in `TaskCommandFactory` |
| Cache Invalidation | Deleting the Redis cache key for a store's premium status after a change | Ensures <60s propagation per PRD requirement |

### API Terms

| Term | Definition | Context |
|------|------------|---------|
| `premium_required` | Error code returned when a non-premium store tries to access gated features | HTTP 403 response body |
| `premiumScheduling` | JSON object in mobile API store info containing premium status and feature flags | Added to `/api/mobile/verify` response |
| `trial_already_used` | Error code when a store tries to start a second trial | HTTP 400 response body |
