# CC Soccer - Checkout State Management Strategy

**Date Created:** December 19, 2024  
**Purpose:** Define how we manage state during registration/checkout to avoid data conflicts, stale values, and validation issues.

**Context:** The D7 system had major issues with state management - data scattered across session, form state, database, multiple caches, and pre-rendered forms. This led to stale data, validation failures, and debugging nightmares. We're building this right from the start in D11.

---

## Core Principle: Single Source of Truth

**Everything lives on the Order entity. Nothing else.**

```
Cart = Order entity with state='draft'

All data stored on order:
├─ Line items (products) → $order->getItems()
├─ Custom checkout data → $order->getData('key')
├─ User reference → $order->getCustomer()
├─ Totals, adjustments → $order->getTotalPrice()
└─ Checkout step progress → handled by Commerce
```

**No session storage. No form state persistence across requests. Just the order.**

---

## What We Want: User Experience

### Cart Persistence
- **Cart items persist** until user removes them or order completes
- **Persists across sessions** - tied to user account, not browser session
- **Multi-device** - Start on phone, finish on laptop
- **Abandoned carts** - Commerce auto-expires draft orders after X days (configurable)

### Checkout Data Persistence
- **YES** - If user abandons mid-checkout, their selections are saved
- **When they return** - Forms pre-fill with saved data
- **Example:** User selects jersey size M, closes browser, comes back tomorrow → size M is still selected
- **Going backwards** - If they navigate back in checkout, data pre-fills from order
- **Overwriting** - New selections overwrite old selections (not appended)

### Cart Conflicts (Season + Tournament)
Cannot checkout both in same order. Handled in **two places:**

1. **Registration form validation** (`/register`) - Prevents adding both
2. **Order item presave hook** - If somehow both get in cart, block and show error

---

## How We Implement: Technical Strategy

### Panes Are Stateless

Each checkout pane is **stateless** - it reads from order, writes to order, stores nothing internally.

**Lifecycle of a pane:**

```php
1. buildForm()
   ↓
   Read current values from $this->order
   ↓
   Set form defaults from order data
   ↓
   Render form

2. validatePaneForm()
   ↓
   Validate user input
   ↓
   Set errors if invalid

3. submitForm()
   ↓
   Write validated data to $this->order
   ↓
   Save order
   ↓
   Pane forgets everything (no internal state)
```

### Where Data Lives

**Player Information (jersey, skill, goalie):**
```php
$data = [
  'jersey_style' => 'unisex',
  'jersey_size' => 'l',
  'self_score' => 7,
  'prefers_goalie' => TRUE,
];
$order->setData('ccsoccer_player_info', $data);
$order->save();
```

**To read it back:**
```php
$data = $order->getData('ccsoccer_player_info', []);
$jersey_size = $data['jersey_size'] ?? 'm'; // default if not set
```

**Agreement Acceptances:**
```php
$agreements = [
  'rec_agreement_accepted' => TRUE,
  'rec_agreement_date' => \Drupal::time()->getRequestTime(),
  'waiver_accepted' => TRUE,
  'waiver_date' => \Drupal::time()->getRequestTime(),
];
$order->setData('ccsoccer_agreements', $agreements);
$order->save();
```

**Credits to Apply (season only):**
```php
$credits = [
  'amount' => 30.00,
  'applied' => FALSE, // Flag to track if adjustment created
];
$order->setData('ccsoccer_credits', $credits);
$order->save();
```

**Tournament Team Selection:**
```php
$team_data = [
  'action' => 'join', // or 'create'
  'team_id' => 5, // if joining
  'team_name' => 'Blue Thunder', // if creating
  'pay_deposit' => TRUE,
];
$order->setData('ccsoccer_tournament_team', $team_data);
$order->save();
```

**Jersey Product in Cart:**
Jersey is a line item, managed by Commerce:
```php
// Get items
$items = $order->getItems();

// Find jersey
foreach ($items as $item) {
  $product = $item->getPurchasedEntity()->getProduct();
  if ($product->bundle() == 'jersey') {
    // Found jersey
  }
}
```

---

## Example: PlayerInfoPane Implementation

**CORRECT - Stateless approach:**

```php
class PlayerInfoPane extends CheckoutPaneBase {

  public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    // Read current values from order (single source of truth)
    $data = $this->order->getData('ccsoccer_player_info', []);
    
    $pane_form['jersey_style'] = [
      '#type' => 'select',
      '#title' => $this->t('Jersey Style'),
      '#options' => ['unisex' => 'Unisex', 'womens' => "Women's"],
      '#default_value' => $data['jersey_style'] ?? 'unisex', // From order
      '#required' => TRUE,
    ];
    
    $pane_form['jersey_size'] = [
      '#type' => 'select',
      '#title' => $this->t('Jersey Size'),
      '#options' => ['s' => 'S', 'm' => 'M', 'l' => 'L', 'xl' => 'XL', 'xxl' => 'XXL'],
      '#default_value' => $data['jersey_size'] ?? 'm', // From order
      '#required' => TRUE,
    ];
    
    // ... other fields
    
    return $pane_form;
  }
  
  public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
    // Get submitted values
    $values = $form_state->getValue($pane_form['#parents']);
    
    // Write to order (single source of truth)
    $data = [
      'jersey_style' => $values['jersey_style'],
      'jersey_size' => $values['jersey_size'],
      'self_score' => $values['self_score'],
      'prefers_goalie' => (bool) $values['prefers_goalie'],
    ];
    $this->order->setData('ccsoccer_player_info', $data);
    $this->order->save();
    
    // Update jersey in cart based on selection
    $this->updateJerseyInCart($values['jersey_style'], $values['jersey_size']);
    
    // Done - pane forgets everything
  }
}
```

**INCORRECT - Storing state in pane:**

```php
class PlayerInfoPane extends CheckoutPaneBase {
  
  // ❌ DON'T DO THIS
  protected $jerseySize;
  protected $selfScore;
  
  public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    // ❌ DON'T DO THIS - reading from pane property
    $default = $this->jerseySize ?? 'm';
    
    $pane_form['jersey_size'] = [
      '#default_value' => $default,
    ];
  }
  
  public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
    // ❌ DON'T DO THIS - storing in pane property
    $this->jerseySize = $form_state->getValue('jersey_size');
    
    // ❌ DON'T DO THIS - storing in form_state
    $form_state->set('jersey_size', $this->jerseySize);
  }
}
```

**Why incorrect approach fails:**
- Pane properties don't persist across requests
- Form state doesn't persist across requests
- User goes back/forward → data lost
- Multi-device → data lost
- Debugging nightmare when data in multiple places

---

## Validation Layers

Validation happens at **four distinct layers**. Each layer has a specific purpose.

### Layer 1: Field-Level Validation (Form API)

Basic required fields, types, formats.

```php
$pane_form['jersey_size'] = [
  '#type' => 'select',
  '#required' => TRUE, // ← Layer 1: Field required
];

$pane_form['self_score'] = [
  '#type' => 'number',
  '#min' => 1, // ← Layer 1: Range validation
  '#max' => 10,
];
```

### Layer 2: Pane-Level Validation (Business Rules)

Business logic specific to this pane.

```php
public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
  $values = $form_state->getValue($pane_form['#parents']);
  
  // Business rule: If goalie selected, recommend higher skill
  if ($values['prefers_goalie'] && $values['self_score'] < 5) {
    $form_state->setError($pane_form['self_score'], 
      $this->t('Goalies should rate themselves 5 or higher.'));
  }
}
```

### Layer 3: Order-Level Validation (Cross-Pane Rules)

Validation that spans multiple panes or checks order-wide constraints.

**File: ccsoccer.module**

```php
/**
 * Implements hook_commerce_order_presave().
 */
function ccsoccer_commerce_order_presave($order) {
  // Only validate draft orders (carts)
  if ($order->getState()->getId() != 'draft') {
    return;
  }
  
  // Check for season + tournament conflict
  $has_season = FALSE;
  $has_tournament = FALSE;
  
  foreach ($order->getItems() as $item) {
    $product = $item->getPurchasedEntity()->getProduct();
    $type = $product->bundle();
    
    if ($type == 'season_registration') {
      $has_season = TRUE;
    }
    if ($type == 'tournament_registration') {
      $has_tournament = TRUE;
    }
  }
  
  if ($has_season && $has_tournament) {
    \Drupal::messenger()->addError(t('You cannot register for both a season and tournament in the same order.'));
    // Prevent save or remove one
    throw new \Exception('Invalid order: contains both season and tournament.');
  }
}
```

### Layer 4: Checkout Flow Validation (Can Proceed?)

Commerce handles this automatically:
- All visible panes complete? → Can continue to next step
- Required panes incomplete? → Cannot proceed
- User goes back? → Can make changes

---

## Handling Cart Conflicts

**Problem:** User adds season to cart, then adds tournament (bypassing `/register` form validation).

**Solution:** Validate at the **order item level** - prevent conflicting items from being added to cart.

**File: ccsoccer.module**

```php
/**
 * Implements hook_ENTITY_TYPE_presave() for commerce_order_item.
 */
function ccsoccer_commerce_order_item_presave(Drupal\commerce_order\Entity\OrderItemInterface $order_item) {
  $order = $order_item->getOrder();
  
  // Skip if not a cart (draft order)
  if (!$order || $order->getState()->getId() != 'draft') {
    return;
  }
  
  // Check what's being added
  $new_product = $order_item->getPurchasedEntity()->getProduct();
  $new_type = $new_product->bundle();
  
  // Only care about season/tournament registrations
  if (!in_array($new_type, ['season_registration', 'tournament_registration'])) {
    return;
  }
  
  // Check what's already in cart
  foreach ($order->getItems() as $existing_item) {
    // Skip the item being added/updated
    if ($existing_item->id() == $order_item->id()) {
      continue;
    }
    
    $existing_product = $existing_item->getPurchasedEntity()->getProduct();
    $existing_type = $existing_product->bundle();
    
    // Detect conflict
    $conflict = (
      ($new_type == 'season_registration' && $existing_type == 'tournament_registration') ||
      ($new_type == 'tournament_registration' && $existing_type == 'season_registration')
    );
    
    if ($conflict) {
      \Drupal::messenger()->addError(t('You cannot register for both a season and tournament in the same order. Please complete one registration at a time.'));
      
      // Prevent adding the conflicting item
      $order_item->delete();
      return;
    }
  }
}
```

This catches conflicts **regardless of how items are added** - via `/register` form, direct product page, cart edit, etc.

---

## Common Scenarios

### Scenario 1: User Abandons Mid-Checkout

**Flow:**
1. User selects jersey size M, skill 7
2. Clicks "Continue to Agreements"
3. Closes browser
4. Returns tomorrow
5. Navigates to `/checkout`

**What happens:**
- Commerce loads their draft order (cart)
- Checkout flow resumes at last completed step
- PlayerInfoPane reads from order: jersey M, skill 7
- Form pre-fills with saved data
- User can continue or change selections

### Scenario 2: User Goes Backward

**Flow:**
1. User completes Player Info (jersey M)
2. User completes Agreements
3. User clicks "Back" to Player Info
4. Changes jersey to L
5. Clicks "Continue"

**What happens:**
- PlayerInfoPane loads with jersey M (current value)
- User selects L
- submitPaneForm() overwrites with L
- Old jersey removed from cart, new jersey added
- User continues to Agreements (still accepted)

### Scenario 3: Multi-Device

**Flow:**
1. User starts on phone, adds season to cart
2. Completes player info on phone
3. Switches to laptop
4. Goes to `/checkout`

**What happens:**
- Same user account, same order
- All data available on laptop
- Continues from where they left off

### Scenario 4: Multiple Seasons

**Flow:**
1. User selects Fall Coed + Fall Mens at `/register`
2. Proceeds to checkout
3. Should show Player Info once (not twice)

**What happens:**
- PlayerInfoPane checks: `isFirstTimeRegistration()`
- User already has jersey? NO → show pane, collect jersey
- User already has jersey? YES → skip pane
- Same jersey applies to both registrations

---

## Anti-Patterns to Avoid

### ❌ Don't Store in Session

```php
// DON'T DO THIS
$_SESSION['jersey_size'] = 'M';
$session = \Drupal::request()->getSession();
$session->set('jersey_size', 'M');
```

**Why bad:** 
- Doesn't persist across devices
- Lost when session expires
- Separate from order data
- Debugging nightmare

### ❌ Don't Store in Form State (Across Requests)

```php
// DON'T DO THIS
public function submitPaneForm() {
  $form_state->set('jersey_size', 'M');
  $form_state->setStorage(['player_data' => $data]);
}
```

**Why bad:**
- Form state doesn't persist across requests
- Lost when user goes back/forward
- Can't access from other panes

### ❌ Don't Store in Pane Properties

```php
// DON'T DO THIS
class PlayerInfoPane extends CheckoutPaneBase {
  protected $jerseySize;
  protected $playerData = [];
}
```

**Why bad:**
- Pane object doesn't persist
- New instance created on each request
- Data lost immediately

### ❌ Don't Cache User-Specific Data

```php
// DON'T DO THIS
$cache->set('user_' . $uid . '_jersey', 'M');
```

**Why bad:**
- Stale data issues
- Cache invalidation complexity
- Not the right tool for transactional data

### ❌ Don't Mix Storage Locations

```php
// DON'T DO THIS
$order->setData('jersey_size', 'M');
$_SESSION['jersey_size'] = 'M';
$form_state->set('jersey_size', 'M');
$user->set('field_jersey_size', 'M')->save();
```

**Why bad:**
- Which is the source of truth?
- Data conflicts inevitable
- Impossible to debug

---

## Benefits of This Approach

✅ **Single source of truth** - Always query the order, never confused
✅ **No stale data** - Forms always rebuild from current order state
✅ **Cross-device** - Order persists to database, not session
✅ **Backwards navigation** - Data pre-fills from order
✅ **Multi-step forms** - Each pane reads from order
✅ **Validation at right layer** - Clear separation of concerns
✅ **No form cache issues** - Stateless panes, no pre-rendering
✅ **Easy debugging** - One place to look (the order)
✅ **Testable** - Can create test orders with known data

---

## Order Data Structure Reference

**Complete list of custom keys we're storing on orders:**

```php
// Player information (season registrations, first-time only)
$order->getData('ccsoccer_player_info')
  └─ [
      'jersey_style' => 'unisex',
      'jersey_size' => 'm',
      'self_score' => 7,
      'prefers_goalie' => TRUE,
    ]

// Agreement acceptances (all registrations)
$order->getData('ccsoccer_agreements')
  └─ [
      'rec_agreement_accepted' => TRUE,
      'rec_agreement_date' => 1734567890,
      'waiver_accepted' => TRUE,
      'waiver_date' => 1734567890,
    ]

// Credits to apply (season registrations only)
$order->getData('ccsoccer_credits')
  └─ [
      'amount' => 30.00,
      'applied' => FALSE,
    ]

// Tournament team selection (tournament registrations only)
$order->getData('ccsoccer_tournament_team')
  └─ [
      'action' => 'join', // or 'create'
      'team_id' => 5,
      'team_name' => 'Blue Thunder',
      'pay_deposit' => TRUE,
    ]
```

---

## When to Actually Create Entities

**During checkout:** Data stored on order only (transient)

**After payment completes:** Create permanent entities

**Hook: `hook_commerce_order_paid_in_full()`**

```php
function ccsoccer_commerce_order_paid_in_full($order) {
  $registration_service = \Drupal::service('ccsoccer.season_registration');
  
  // Extract data from order
  $player_info = $order->getData('ccsoccer_player_info', []);
  $agreements = $order->getData('ccsoccer_agreements', []);
  
  // Create Registration entity
  foreach ($order->getItems() as $item) {
    $product = $item->getPurchasedEntity()->getProduct();
    
    if ($product->bundle() == 'season_registration') {
      $registration = $registration_service->createFromOrder($order, $item, $player_info, $agreements);
    }
  }
  
  // Create Credits entity if credits were used
  $credits = $order->getData('ccsoccer_credits', []);
  if (!empty($credits['amount'])) {
    // Create credit transaction
  }
}
```

**Why wait until payment?**
- Don't create entities for abandoned carts
- Clean data - only successful registrations
- Easy rollback if payment fails

---

## Debugging Tips

**Check order data:**
```php
ddev drush php:eval "
\$order = \Drupal::entityTypeManager()->getStorage('commerce_order')->load(1);
print_r(\$order->getData('ccsoccer_player_info'));
"
```

**Check order items:**
```php
ddev drush php:eval "
\$order = \Drupal::entityTypeManager()->getStorage('commerce_order')->load(1);
foreach (\$order->getItems() as \$item) {
  echo \$item->getTitle() . PHP_EOL;
}
"
```

**Clear all carts (testing):**
```php
ddev drush php:eval "
\$orders = \Drupal::entityTypeManager()->getStorage('commerce_order')->loadByProperties(['state' => 'draft']);
foreach (\$orders as \$order) {
  \$order->delete();
}
echo 'Deleted ' . count(\$orders) . ' draft orders';
"
```

---

## Summary: The Rules

1. **Store everything on the order** - Use `$order->setData('key', $data)`
2. **Read from order on every request** - Never assume pane knows anything
3. **Panes are stateless** - Build, validate, submit, forget
4. **Validate at the right layer** - Field → Pane → Order → Flow
5. **Handle conflicts at order level** - Use hooks, not form validation alone
6. **Create permanent entities after payment** - Not during checkout
7. **Never use session, form_state, or pane properties** - Order only

---

**Questions or concerns? Discuss with Caleb before deviating from this strategy.**

**Last Updated:** December 19, 2024
