# Solution Design Document

## 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 **Technology Stack**: PHP 8.x, Slim 2.6.2, PSR-4 autoloading, Predis for Redis, PDO for database, curl for HTTP
CON-2 **Backward Compatibility**: All existing API endpoints must continue working with same request/response format
CON-3 **FSRunner Architecture**: Beanstalkd queue system must be preserved; FSRunner must remain a standalone daemon
CON-4 **Testing Infrastructure**: PHPUnit with existing test support classes (PdoMockBuilder, RedisMock, StoreMock)
CON-5 **Security**: No credentials in source code; all secrets via environment variables

## Implementation Context

**IMPORTANT**: You MUST read and analyze ALL listed context sources to understand constraints, patterns, and existing architecture.

### Required Context Sources

- ICO-1 [Modern Pattern Reference - Chat System]
```yaml
# Internal documentation and patterns
- file: userfrosting/src/BuyerKiosk/Chat/Services/ChatBillingService.php
  relevance: HIGH
  why: "Modern service pattern with dependency injection, factory methods, PHPDoc"

- file: userfrosting/src/BuyerKiosk/Chat/Services/ChatTemplateService.php
  relevance: HIGH
  why: "Example of pure service with no external dependencies, comprehensive docs"

- doc: docs/patterns/psr4-autoloading.md
  relevance: MEDIUM
  why: "PSR-4 namespace conventions that must be followed"
```

- ICO-2 [Current FiveStars Implementation]
```yaml
# Source code files that must be understood
- file: userfrosting/src/BuyerKiosk/FiveStars/API.php
  relevance: HIGH
  why: "Main app API client - uses env vars correctly"

- file: FSRunner/class/API.php
  relevance: CRITICAL
  why: "FSRunner API client - HARDCODED CREDENTIALS that must be removed"

- file: userfrosting/src/BuyerKiosk/FiveStars/Controllers/StoreController.php
  relevance: HIGH
  why: "Business logic that will move to services"

- file: userfrosting/src/BuyerKiosk/FiveStars/Controllers/RewardController.php
  relevance: HIGH
  why: "Reward logic with Redis caching pattern"

- file: userfrosting/routes/groups/fivestars.php
  relevance: HIGH
  why: "Route definitions - shows missing auth on reports endpoint"

- file: FSRunner/controller/FiveStarsGlobalController.php
  relevance: MEDIUM
  why: "FSRunner controller that uses the duplicate API class"
```

- ICO-3 [Testing Infrastructure]
```yaml
- file: userfrosting/tests/Mocks/PdoMockBuilder.php
  relevance: HIGH
  why: "Fluent PDO mocking for unit tests"

- file: userfrosting/tests/Mocks/RedisMock.php
  relevance: MEDIUM
  why: "In-memory Redis mock for service tests"

- file: userfrosting/tests/Mocks/StoreMock.php
  relevance: HIGH
  why: "Store mock with FiveStars config support"
```

### Implementation Boundaries

- **Must Preserve**: All API endpoint URLs and response formats, Beanstalkd queue tube names, Redis cache key patterns
- **Can Modify**: Internal class structure, method signatures (with backward compat), error messages
- **Must Not Touch**: `fsOutgoing` table schema, `fiveStarsPoints` table schema, `dailySalesData` table schema

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    POS[POS System] -->|Sales Data| API[FiveStars API Routes]
    Admin[Store Admin] -->|Reports/Settings| AdminUI[Admin Templates]

    API --> FSServices[FiveStars Services]
    AdminUI --> FSServices

    FSServices --> Redis[(Redis Cache)]
    FSServices --> StoreDB[(Store DB)]
    FSServices --> CentralDB[(Central DB)]
    FSServices --> FSApiClient[FiveStars API Client]

    FSApiClient -->|HTTPS| FiveStarsAPI[FiveStars API]

    Beanstalkd[Beanstalkd Queue] --> FSRunner[FSRunner Daemon]
    FSRunner --> FSApiClient
    FSRunner --> CentralDB

    FSServices -->|Queue Jobs| Beanstalkd
```

#### Interface Specifications

```yaml
# Inbound Interfaces (what calls this system)
inbound:
  - name: "POS API Integration"
    type: HTTPS
    format: REST/JSON
    authentication: Store API Key (validateAPIKey function)
    endpoints:
      - POST /:typeNum/rewards
      - POST /:typeNum/rewards/by-phone
      - POST /:typeNum/rewards/redeem
      - POST /:typeNum/members/add
      - POST /:typeNum/runPointsCalc
    data_flow: "Sales data ingestion, reward operations"

  - name: "Admin Reports API"
    type: HTTPS
    format: REST/JSON
    authentication: Session-based (NEEDS API key validation added)
    endpoints:
      - POST /:typeNum/reports/points/getByDateRange
    data_flow: "Points transaction reports"

# Outbound Interfaces (what this system calls)
outbound:
  - name: "FiveStars Unified API"
    type: HTTPS
    format: REST/JSON
    authentication: HTTP Basic Auth (API Key:Secret)
    base_urls:
      production: https://api.fivestars.com/api/unified/
      sandbox: https://api.partnersandbox.fivestars.com/api/unified/
    endpoints:
      - GET /businesses/{softwareID}/rewards
      - GET /businesses/{softwareID}/perks
      - GET /businesses/{softwareID}/memberships/by-phone/{phoneHash}
      - POST /businesses/{softwareID}/sales
      - POST /businesses/{softwareID}/memberships
    criticality: HIGH

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: PDO via dbConnectByName()
    tables:
      - fiveStarsPoints (points aggregations)
      - dailySalesData (raw sales transactions)

  - name: "Central Database"
    type: MySQL
    connection: PDO via dbConnectByName($db_name)
    tables:
      - fsOutgoing (points posting queue)
      - salesPostHash (duplicate prevention)

  - name: "Redis Cache"
    type: Redis
    connection: Predis via $_ENV['REDIS_URL']
    keys:
      - {typeNum}_rewards (2 hour TTL)
      - {typeNum}_promotions (24 hour TTL)
```

### Cross-Component Boundaries

- **API Contracts**: Route signatures and response formats are public contracts
- **Team Ownership**: FiveStars module owned by core team
- **Shared Resources**: Central DB (`kiosk_buykiosk`) shared across modules
- **Breaking Change Policy**: Deprecate old patterns, support both during transition

### Project Commands

```bash
# Component: Main Application
Location: /Users/rvanvuren/Projects/buyerkiosk-web/userfrosting

## Environment Setup
Install Dependencies: cd userfrosting && composer install
Environment Variables: See .env.example (FS_API_URL, FS_API_KEY, FS_API_SECRET, etc.)
Start Development: Local Apache with PHP 8.x

# Testing Commands
Unit Tests: ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
All Tests: ./test.sh
Test Coverage: ./test.sh --coverage
PHPStan Analysis: ./test.sh --stan

# Code Quality Commands
Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse
Type Checking: PHPStan level as configured

# Component: FSRunner Daemon
Location: /Users/rvanvuren/Projects/buyerkiosk-web/FSRunner

## Environment Setup
Dependencies: Shared via userfrosting/vendor (Beanstalkd, Predis)
Run Daemon: php FSRunner.php

# Multi-Component Coordination
Run All Tests: ./test.sh
Deploy All: ./deploy.sh
```

## Solution Strategy

- **Architecture Pattern**: Service-Oriented Architecture with thin controllers and rich services
- **Integration Approach**: Extract business logic from controllers into services; create unified API client shared between main app and FSRunner
- **Justification**:
  - Matches existing Chat system patterns for consistency
  - Services enable testability through dependency injection
  - Unified client eliminates code duplication and credential inconsistency
- **Key Decisions**:
  1. Single `FiveStarsApiClient` replaces both existing API classes
  2. Services layer (`Services/`) handles all business logic
  3. Controllers become thin routing layers
  4. Factory methods enable testing without global dependencies

## Building Block View

### Components

```mermaid
graph TB
    subgraph "Routes Layer"
        R[fivestars.php routes]
    end

    subgraph "Controllers Layer"
        SC[StoreController]
        RC[RewardController]
    end

    subgraph "Services Layer - NEW"
        PS[PointsService]
        RS[RewardsService]
        SS[SalesIngestionService]
    end

    subgraph "Infrastructure Layer"
        AC[FiveStarsApiClient - UNIFIED]
        Cache[Redis Cache]
    end

    subgraph "Models Layer"
        Points[Points]
        Sale[Sale]
        Reward[Reward]
    end

    R --> SC
    R --> RC
    SC --> PS
    SC --> SS
    RC --> RS

    PS --> AC
    RS --> AC
    SS --> Points
    SS --> Sale

    RS --> Cache
    PS --> Points

    subgraph "FSRunner Daemon"
        FSR[FSRunner]
        FSGC[FiveStarsGlobalController]
    end

    FSR --> FSGC
    FSGC --> AC
```

### Directory Map

**Component**: Main Application
```
userfrosting/src/BuyerKiosk/FiveStars/
├── Services/                           # NEW: Business logic services
│   ├── FiveStarsApiClient.php          # NEW: Unified API client with retry/timeout
│   ├── PointsService.php               # NEW: Points calculation and posting
│   ├── RewardsService.php              # NEW: Reward lookup and redemption
│   └── SalesIngestionService.php       # NEW: Sales data processing
├── Controllers/
│   ├── BaseController.php              # MODIFY: Inject services
│   ├── StoreController.php             # MODIFY: Delegate to services
│   └── RewardController.php            # MODIFY: Delegate to services
├── Models/
│   ├── Points.php                      # EXISTING: Keep as data model
│   ├── Sale.php                        # EXISTING: Keep as data model
│   └── Reward.php                      # EXISTING: Keep as data model
├── Exceptions/                         # NEW: Typed exceptions
│   ├── FiveStarsApiException.php       # NEW: API errors
│   ├── FiveStarsConfigException.php    # NEW: Configuration errors
│   └── FiveStarsValidationException.php # NEW: Input validation errors
└── API.php                             # DELETE: Replaced by FiveStarsApiClient
```

**Component**: FSRunner Daemon
```
FSRunner/
├── class/
│   └── API.php                         # DELETE: Use shared FiveStarsApiClient
├── controller/
│   └── FiveStarsGlobalController.php   # MODIFY: Use shared client via injection
└── FSRunner.php                        # MODIFY: Bootstrap shared autoloader
```

**Component**: Tests
```
userfrosting/tests/
├── Unit/FiveStars/                     # NEW: Unit test directory
│   ├── Services/
│   │   ├── FiveStarsApiClientTest.php  # NEW
│   │   ├── PointsServiceTest.php       # NEW
│   │   ├── RewardsServiceTest.php      # NEW
│   │   └── SalesIngestionServiceTest.php # NEW
│   └── Models/
│       └── PointsTest.php              # NEW
├── Integration/FiveStars/              # NEW: Integration tests
│   └── ApiClientIntegrationTest.php    # NEW
└── Mocks/
    └── FiveStarsApiMock.php            # NEW: Mock for API client
```

### Interface Specifications

#### Data Storage Changes

```yaml
# No database schema changes required
# Existing tables remain unchanged:
# - fiveStarsPoints (store-level)
# - dailySalesData (store-level)
# - fsOutgoing (central)
# - salesPostHash (central)

# Redis cache keys remain unchanged:
# - {typeNum}_rewards (2 hour TTL)
# - {typeNum}_promotions (24 hour TTL)
```

#### Internal API Changes

```yaml
# No changes to external API endpoints
# All routes remain at same paths with same request/response formats

# Internal change: Add API key validation to reports endpoint
Endpoint: Points Report
  Path: POST /:typeNum/reports/points/getByDateRange
  CHANGE: Add validateAPIKey() check (matches other endpoints)
  Request: UNCHANGED
  Response: UNCHANGED
```

#### Application Data Models

```pseudocode
# FiveStarsApiClient (NEW)
CLASS: FiveStarsApiClient
  CONSTANTS:
    DEFAULT_CONNECT_TIMEOUT: 10 (seconds)
    DEFAULT_REQUEST_TIMEOUT: 30 (seconds)
    MAX_RETRIES: 3

  FIELDS:
    - apiUrl: string
    - apiKey: string
    - apiSecret: string
    - connectTimeout: int
    - requestTimeout: int
    - logger: KLogger|null

  CONSTRUCTOR(
    apiUrl: string,
    apiKey: string,
    apiSecret: string,
    connectTimeout: int = DEFAULT_CONNECT_TIMEOUT,
    requestTimeout: int = DEFAULT_REQUEST_TIMEOUT,
    logger: KLogger|null = null
  )
    VALIDATE: apiUrl not empty
    VALIDATE: apiKey not empty
    VALIDATE: apiSecret not empty
    THROW FiveStarsConfigException if invalid

  STATIC createFromEnvironment(isDev: bool = false): self
    IF isDev:
      apiUrl = $_ENV['FS_API_URL_DEV']
      apiKey = $_ENV['FS_API_KEY_DEV']
      apiSecret = $_ENV['FS_API_SECRET_DEV']
    ELSE:
      apiUrl = $_ENV['FS_API_URL']
      apiKey = $_ENV['FS_API_KEY']
      apiSecret = $_ENV['FS_API_SECRET']
    THROW FiveStarsConfigException if any env var missing
    RETURN new self(apiUrl, apiKey, apiSecret)

  METHOD call(endpoint: string, data: array|null = null, method: string = 'GET'): array
    FOR attempt = 1 TO MAX_RETRIES:
      TRY:
        response = executeRequest(endpoint, data, method)
        RETURN parseResponse(response)
      CATCH TransientError:
        IF attempt < MAX_RETRIES:
          sleep(exponentialBackoff(attempt))
          CONTINUE
        THROW FiveStarsApiException
      CATCH PermanentError:
        THROW FiveStarsApiException (no retry)

  METHOD phoneHash(phoneNumber: string): string
    RETURN sha1(phoneNumber)

# PointsService (NEW)
CLASS: PointsService
  FIELDS:
    - store: Store
    - storeDb: PDO
    - centralDb: PDO
    - apiClient: FiveStarsApiClient
    - logger: KLogger

  CONSTRUCTOR(
    store: Store,
    storeDb: PDO,
    centralDb: PDO,
    apiClient: FiveStarsApiClient,
    logger: KLogger|null = null
  )

  STATIC createWithDefaults(store: Store): self
    global $db_name
    storeDb = dbConnectByName(store.getDbName())
    centralDb = dbConnectByName($db_name)
    apiClient = FiveStarsApiClient::createFromEnvironment(store.getDev())
    RETURN new self(store, storeDb, centralDb, apiClient)

  METHOD calculatePointsForDay(date: string): array
    // Existing logic from StoreController::getPointsForDay()

  METHOD queuePointsForPosting(date: string): int
    // Existing logic from StoreController::getPostsForDay()

  METHOD getPointsReportByDateRange(startDate: string, endDate: string, showBuys: bool): array
    // Existing logic from StoreController::getPointsReportByDateRange()

# RewardsService (NEW)
CLASS: RewardsService
  FIELDS:
    - store: Store
    - storeDb: PDO
    - apiClient: FiveStarsApiClient
    - redis: Predis\Client
    - logger: KLogger

  CONSTRUCTOR(
    store: Store,
    storeDb: PDO,
    apiClient: FiveStarsApiClient,
    redis: Predis\Client,
    logger: KLogger|null = null
  )

  STATIC createWithDefaults(store: Store): self
    storeDb = dbConnectByName(store.getDbName())
    apiClient = FiveStarsApiClient::createFromEnvironment(store.getDev())
    redis = new Predis\Client($_ENV['REDIS_URL'])
    RETURN new self(store, storeDb, apiClient, redis)

  METHOD getAllRewards(): array
    cacheKey = store.getTypeNum() . "_rewards"
    IF redis.exists(cacheKey):
      RETURN json_decode(redis.get(cacheKey))
    rewards = apiClient.call(endpointBase . "/rewards")
    redis.set(cacheKey, json_encode(rewards))
    redis.expire(cacheKey, 7200) // 2 hours
    RETURN rewards

  METHOD getPointsAndRewardsByPhone(phone: string): array
    // Existing logic from RewardController

  METHOD redeemReward(phone: string, rewards: array): array
    // Existing logic from RewardController::redeemRewardByPhone()
```

#### Integration Points

```yaml
# Inter-Component Communication
- from: Main Application
  to: FSRunner Daemon
  protocol: Beanstalkd Queue
  tube: fsOutgoing
  data_flow: "Points records queued for posting to FiveStars API"

# External System Integration
FiveStars_API:
  base_url_prod: https://api.fivestars.com/api/unified/
  base_url_dev: https://api.partnersandbox.fivestars.com/api/unified/
  authentication: HTTP Basic Auth
  endpoints_used:
    - GET /businesses/{id}/rewards
    - GET /businesses/{id}/perks
    - GET /businesses/{id}/memberships/by-phone/{hash}
    - POST /businesses/{id}/sales
    - POST /businesses/{id}/memberships
  critical_data: [phone_hash, points, reward_uid]
```

### Implementation Examples

#### Example: FiveStarsApiClient with Retry Logic

**Why this example**: Demonstrates the core retry pattern with exponential backoff that all API calls must follow.

```php
<?php
namespace BuyerKiosk\FiveStars\Services;

use BuyerKiosk\FiveStars\Exceptions\FiveStarsApiException;
use BuyerKiosk\FiveStars\Exceptions\FiveStarsConfigException;

class FiveStarsApiClient
{
    private const DEFAULT_CONNECT_TIMEOUT = 10;
    private const DEFAULT_REQUEST_TIMEOUT = 30;
    private const MAX_RETRIES = 3;

    private string $apiUrl;
    private string $apiKey;
    private string $apiSecret;
    private int $connectTimeout;
    private int $requestTimeout;
    private ?\KLogger $logger;

    public function __construct(
        string $apiUrl,
        string $apiKey,
        string $apiSecret,
        int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT,
        int $requestTimeout = self::DEFAULT_REQUEST_TIMEOUT,
        ?\KLogger $logger = null
    ) {
        if (empty($apiUrl) || empty($apiKey) || empty($apiSecret)) {
            throw new FiveStarsConfigException('API URL, key, and secret are required');
        }

        $this->apiUrl = rtrim($apiUrl, '/') . '/';
        $this->apiKey = $apiKey;
        $this->apiSecret = $apiSecret;
        $this->connectTimeout = $connectTimeout;
        $this->requestTimeout = $requestTimeout;
        $this->logger = $logger;
    }

    public static function createFromEnvironment(bool $isDev = false): self
    {
        $suffix = $isDev ? '_DEV' : '';

        $apiUrl = $_ENV['FS_API_URL' . $suffix] ?? null;
        $apiKey = $_ENV['FS_API_KEY' . $suffix] ?? null;
        $apiSecret = $_ENV['FS_API_SECRET' . $suffix] ?? null;

        if (empty($apiUrl) || empty($apiKey) || empty($apiSecret)) {
            throw new FiveStarsConfigException(
                'Missing required environment variables: FS_API_URL' . $suffix .
                ', FS_API_KEY' . $suffix . ', FS_API_SECRET' . $suffix
            );
        }

        return new self($apiUrl, $apiKey, $apiSecret);
    }

    public function call(string $endpoint, ?array $data = null, string $method = 'GET'): array
    {
        $lastException = null;

        for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) {
            try {
                return $this->executeRequest($endpoint, $data, $method);
            } catch (FiveStarsApiException $e) {
                $lastException = $e;

                // Don't retry 4xx errors (permanent failures)
                if ($e->getCode() >= 400 && $e->getCode() < 500) {
                    throw $e;
                }

                // Retry 5xx and network errors
                if ($attempt < self::MAX_RETRIES) {
                    $delay = pow(2, $attempt - 1); // 1s, 2s, 4s
                    $this->log("Retry {$attempt}/{self::MAX_RETRIES} after {$delay}s: " . $e->getMessage());
                    sleep($delay);
                }
            }
        }

        throw $lastException ?? new FiveStarsApiException('Max retries exceeded');
    }

    public function phoneHash(string $phoneNumber): string
    {
        return sha1($phoneNumber);
    }

    private function executeRequest(string $endpoint, ?array $data, string $method): array
    {
        $ch = curl_init($this->apiUrl . ltrim($endpoint, '/'));

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
            CURLOPT_TIMEOUT => $this->requestTimeout,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_USERPWD => $this->apiKey . ':' . $this->apiSecret,
        ]);

        if ($data !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        }

        if ($method === 'PATCH') {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($error) {
            throw new FiveStarsApiException("Network error: {$error}", 0);
        }

        $decoded = json_decode($response, true);

        if ($httpCode >= 400) {
            throw new FiveStarsApiException(
                $decoded['message'] ?? "HTTP {$httpCode}",
                $httpCode
            );
        }

        return $decoded ?? [];
    }

    private function log(string $message): void
    {
        $this->logger?->LogDebug($message);
    }
}
```

#### Example: Service with Factory Method Pattern

**Why this example**: Shows how services use dependency injection while providing factory methods for production use.

```php
<?php
namespace BuyerKiosk\FiveStars\Services;

use PDO;
use Store;
use KLogger;

class PointsService
{
    private Store $store;
    private PDO $storeDb;
    private PDO $centralDb;
    private FiveStarsApiClient $apiClient;
    private ?KLogger $logger;

    public function __construct(
        Store $store,
        PDO $storeDb,
        PDO $centralDb,
        FiveStarsApiClient $apiClient,
        ?KLogger $logger = null
    ) {
        $this->store = $store;
        $this->storeDb = $storeDb;
        $this->centralDb = $centralDb;
        $this->apiClient = $apiClient;
        $this->logger = $logger;
    }

    /**
     * Factory method for production use
     */
    public static function createWithDefaults(Store $store): self
    {
        global $db_name;

        $storeDb = dbConnectByName($store->getDbName());
        $centralDb = dbConnectByName($db_name);
        $apiClient = FiveStarsApiClient::createFromEnvironment((bool)$store->getDev());
        $logger = new KLogger($_ENV['LOG_DIR'] . '/fiveStars.log', KLogger::DEBUG);

        return new self($store, $storeDb, $centralDb, $apiClient, $logger);
    }

    public function calculatePointsForDay(string $date): array
    {
        // Business logic extracted from StoreController
    }
}
```

## Runtime View

### Primary Flow

#### Primary Flow: Points Posting
1. POS sends sales data to API endpoint
2. System validates API key and JSON payload
3. SalesIngestionService processes each transaction
4. PointsService calculates points per customer
5. Points queued to `fsOutgoing` for async posting
6. FSRunner picks up queue jobs
7. FiveStarsApiClient posts to FiveStars API (with retries)
8. Success/failure recorded in `fsOutgoing`

```mermaid
sequenceDiagram
    actor POS
    participant Routes
    participant StoreController
    participant SalesIngestionService
    participant PointsService
    participant Queue as Beanstalkd
    participant FSRunner
    participant ApiClient as FiveStarsApiClient
    participant FiveStars as FiveStars API

    POS->>Routes: POST /api/{typeNum}/runPointsCalc
    Routes->>Routes: validateAPIKey()
    Routes->>StoreController: handle request
    StoreController->>SalesIngestionService: processSalesData()
    SalesIngestionService->>PointsService: calculatePointsForDay()
    PointsService->>PointsService: INSERT fiveStarsPoints
    PointsService->>Queue: INSERT fsOutgoing

    Note over FSRunner,FiveStars: Async Processing

    FSRunner->>Queue: Fetch pending jobs
    FSRunner->>ApiClient: postPoints()

    loop Retry on failure
        ApiClient->>FiveStars: POST /sales
        alt Success
            FiveStars-->>ApiClient: 200 OK
            ApiClient-->>FSRunner: Success
            FSRunner->>Queue: Mark posted=1
        else Transient Error (5xx)
            FiveStars-->>ApiClient: 500 Error
            ApiClient->>ApiClient: Exponential backoff
            ApiClient->>FiveStars: Retry
        else Permanent Error (4xx)
            FiveStars-->>ApiClient: 400 Error
            ApiClient-->>FSRunner: Throw exception
            FSRunner->>Queue: Record error
        end
    end
```

### Error Handling

- **Invalid input (400)**: Return JSON error with specific field validation messages
- **Unauthorized (401)**: Return "Unauthorized" message, log attempt
- **Network failure**: Retry with exponential backoff (1s, 2s, 4s), then fail
- **API rate limit (429)**: Respect Retry-After header, then retry
- **FiveStars API error (4xx)**: Do not retry, log error, update fsOutgoing with error
- **FiveStars API error (5xx)**: Retry up to 3 times, then record failure

### Complex Logic: Points Calculation

```
ALGORITHM: Calculate Points for Customer
INPUT: date, store_config
OUTPUT: points_records[]

1. QUERY: SELECT phoneNum, SUM(salesAmount), SUM(taxAmount), SUM(buyAmount)
          FROM dailySalesData
          WHERE date = :date
          GROUP BY phoneNum

2. FOR EACH customer_aggregate:
   2.1 points_base = salesAmount

   2.2 IF store.fiveStarsTaxIncluded:
       points_base += taxAmount

   2.3 IF store.fiveStarsBuysIncluded:
       points_base += buyAmount

   2.4 IF points_base > 0:
       points = FLOOR(points_base / store.fiveStarsPointsRatio)
   ELSE IF points_base < 0:
       points = CEIL(points_base / store.fiveStarsPointsRatio)
   ELSE:
       points = 0

   2.5 INSERT INTO fiveStarsPoints(date, phone, totalSales, totalBuys, totalTax, points)

3. RETURN count of records processed
```

## Deployment View

### Single Application Deployment

- **Environment**: Production Linux servers with PHP 8.x, Apache
- **Configuration**: Required environment variables:
  ```bash
  # Production
  FS_API_URL=https://api.fivestars.com/api/unified/
  FS_API_KEY=<production_key>
  FS_API_SECRET=<production_secret>

  # Development (optional)
  FS_API_URL_DEV=https://api.partnersandbox.fivestars.com/api/unified/
  FS_API_KEY_DEV=<sandbox_key>
  FS_API_SECRET_DEV=<sandbox_secret>

  # Shared
  REDIS_URL=<redis_connection_string>
  LOG_DIR=/path/to/logs
  ```
- **Dependencies**: Redis, MySQL, Beanstalkd
- **Performance**: 99%+ API success rate, <2s response time for reports

### Multi-Component Coordination

- **Deployment Order**:
  1. Deploy main application with new services
  2. Verify API endpoints work with new client
  3. Deploy FSRunner changes (uses shared client)
  4. Verify queue processing works
  5. Rotate credentials (coordinate with deployment)

- **Rollback Strategy**:
  - Keep old API classes for 1 release cycle
  - Feature flag for new vs old client (if needed)
  - Monitor fsOutgoing error rate

- **Credential Rotation**:
  1. Generate new FiveStars API credentials
  2. Add new credentials to environment
  3. Deploy code that uses env vars
  4. Verify functionality
  5. Revoke old credentials

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used in this feature
- pattern: PSR-4 Autoloading
  relevance: HIGH
  why: "All new classes must follow namespace conventions"

- pattern: Service Layer with Factory Methods
  relevance: CRITICAL
  why: "Core architectural pattern for testability - see Chat system"

- pattern: Dependency Injection
  relevance: HIGH
  why: "All services accept dependencies via constructor"

# New patterns created for this feature
- pattern: API Client with Retry (NEW)
  relevance: HIGH
  why: "Resilience pattern for external API calls"
```

### System-Wide Patterns

- **Security**: All credentials via environment variables; API key validation on all endpoints
- **Error Handling**: Typed exceptions (FiveStarsApiException, FiveStarsConfigException); all errors logged
- **Performance**: Redis caching for rewards (2hr) and promotions (24hr); connection pooling via PDO
- **Logging**: KLogger with DEBUG level; structured messages with context

### Implementation Patterns

#### Error Handling Pattern

```pseudocode
# Exception hierarchy
FiveStarsException (abstract base)
├── FiveStarsConfigException (missing env vars, invalid config)
├── FiveStarsApiException (HTTP errors, network failures)
└── FiveStarsValidationException (invalid input data)

FUNCTION handle_api_call(operation)
  TRY:
    result = api_client.call(endpoint, data)
    RETURN success_response(result)
  CATCH FiveStarsConfigException:
    LOG error with config context
    THROW (fail fast - misconfiguration)
  CATCH FiveStarsApiException:
    LOG error with request/response context
    IF retryable:
      RETURN from retry logic
    RETURN error_response(safe_message)
  CATCH FiveStarsValidationException:
    RETURN validation_error_response(field_errors)
```

#### Test Pattern

```pseudocode
TEST_SCENARIO: "API client retries on transient failure"
  SETUP:
    mock_http = create_mock()
    mock_http.expect_call(1).return_error(500)
    mock_http.expect_call(2).return_error(503)
    mock_http.expect_call(3).return_success()
    client = new FiveStarsApiClient(mock_http)

  EXECUTE:
    result = client.call("/endpoint")

  VERIFY:
    mock_http.verify_call_count(3)
    result.success == true

TEST_SCENARIO: "API client does not retry 4xx errors"
  SETUP:
    mock_http.expect_call(1).return_error(400)
    client = new FiveStarsApiClient(mock_http)

  EXECUTE:
    EXPECT_EXCEPTION FiveStarsApiException:
      client.call("/endpoint")

  VERIFY:
    mock_http.verify_call_count(1) // No retries
```

## Architecture Decisions

- [x] ADR-1 **Unified API Client**: Single FiveStarsApiClient shared between main app and FSRunner
  - Rationale: Eliminates code duplication, ensures consistent credential handling
  - Trade-offs: FSRunner needs autoloader access to shared code
  - User confirmed: Yes

- [x] ADR-2 **Service Layer Architecture**: Extract business logic to Services/, keep Controllers thin
  - Rationale: Matches Chat system patterns, enables unit testing
  - Trade-offs: More files to maintain, migration effort
  - User confirmed: Yes

- [x] ADR-3 **Environment Variables for Credentials**: All API credentials via $_ENV
  - Rationale: Security best practice, no secrets in code
  - Trade-offs: Requires environment configuration on all servers
  - User confirmed: Yes

- [x] ADR-4 **Retry with Exponential Backoff**: 3 retries with 1s, 2s, 4s delays
  - Rationale: Handles transient failures without overwhelming API
  - Trade-offs: Slightly longer failure scenarios (7s worst case)
  - User confirmed: Yes

## Quality Requirements

- **Performance**: API calls complete in <30s; reports return in <5s for 90-day range
- **Reliability**: 99%+ points posting success rate; zero data loss from retries
- **Security**: No credentials in source code; all endpoints authenticated
- **Testability**: 80%+ code coverage; all services unit testable

## Risks and Technical Debt

### Known Technical Issues

- FSRunner has hardcoded credentials (CRITICAL - must fix)
- Reports endpoint missing API key validation (HIGH - must fix)
- Inconsistent logging levels (LogError used for success messages)

### Technical Debt

- Two duplicate API client classes
- Global variable usage in controllers
- Missing type hints on older code
- No PHPDoc on most existing methods

### Implementation Gotchas

- FSRunner runs as standalone daemon - must bootstrap Composer autoloader
- Store.getDev() returns 0/1 as string, not boolean
- Phone numbers hashed with SHA1 (FiveStars requirement, not configurable)
- Redis key format must match exactly (`{typeNum}_rewards`)

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Successful Points Calculation**
```gherkin
Given: Store has FiveStars configured with ratio 1.0
And: dailySalesData has sales records for date
When: PointsService.calculatePointsForDay(date) is called
Then: fiveStarsPoints records are created
And: Points equal sum of sales divided by ratio
```

**Scenario 2: API Client Retry on Transient Failure**
```gherkin
Given: FiveStars API returns 500 on first two calls
And: FiveStars API returns 200 on third call
When: ApiClient.call() is invoked
Then: Three HTTP requests are made
And: Final response is success
And: Delays follow exponential backoff (1s, 2s)
```

**Scenario 3: API Client No Retry on 4xx**
```gherkin
Given: FiveStars API returns 400 Bad Request
When: ApiClient.call() is invoked
Then: Exactly one HTTP request is made
And: FiveStarsApiException is thrown
```

**Scenario 4: Missing Environment Variables**
```gherkin
Given: FS_API_KEY environment variable is not set
When: FiveStarsApiClient::createFromEnvironment() is called
Then: FiveStarsConfigException is thrown
And: Error message lists missing variable name
```

### Test Coverage Requirements

- **Business Logic**: Points calculation, reward filtering, redemption deduplication
- **API Client**: Retry logic, timeout handling, error classification
- **Services**: All public methods with dependency injection
- **Edge Cases**: Empty results, network timeouts, malformed responses
- **Security**: API key validation, credential loading

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Points | Loyalty reward units earned from purchases | Calculated from sales, posted to FiveStars |
| Reward | Redeemable benefit (discount, free item) | Has UID and point cost |
| Software ID | BuyerKiosk's identifier at FiveStars | Used in API endpoint paths |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| fsOutgoing | Queue table for pending points posts | Central DB, processed by FSRunner |
| Phone Hash | SHA1 hash of customer phone | Required by FiveStars API for lookups |
| Transient Error | Temporary failure (5xx, network) | Eligible for retry |
| Permanent Error | Client error (4xx) | Not retried |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| Unified API | FiveStars REST API version | Base URL for all operations |
| typeNum | Store identifier pattern | Route parameter, e.g., "ou00" |
