# In-App Purchase & Subscription System Design

*Created: 2026-01-13*

## Overview

Implement a freemium subscription model using StoreKit 2 with local-only entitlement checking. No backend required.

## Monetization Model

**Pricing:**
- Monthly: $4.99/month
- Annual: $49.99/year (17% discount)
- Family Sharing: Enabled

**Free Tier:**
- Basic trip planning (route optimization, must-see games, schedule viewing)
- 1 saved trip

**Pro Tier:**
- Unlimited saved trips
- PDF itinerary export
- Progress tracking (stadium visits, achievements, bucket list)

## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│                         StoreManager                             │
│                    (@Observable, singleton)                      │
│  - products: [Product]           - Fetches available products   │
│  - purchasedProductIDs: Set      - Tracks active entitlements   │
│  - isPro: Bool (computed)        - Single source of truth       │
└─────────────────────────────────────────────────────────────────┘
                                │
                    ┌───────────┴───────────┐
                    ▼                       ▼
           ┌───────────────┐       ┌───────────────┐
           │ PaywallView   │       │ ProGate       │
           │ (Full screen) │       │ (View modifier)│
           └───────────────┘       └───────────────┘
```

**Product IDs:**
- `com.sportstime.pro.monthly`
- `com.sportstime.pro.annual`

## Entitlement Checking

StoreKit 2's `Transaction.currentEntitlements` provides all active transactions. StoreManager refreshes on app launch and transaction updates:

```swift
@Observable
final class StoreManager {
    private(set) var purchasedProductIDs: Set<String> = []

    var isPro: Bool {
        !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
    }

    private static let proProductIDs: Set<String> = [
        "com.sportstime.pro.monthly",
        "com.sportstime.pro.annual"
    ]

    func updateEntitlements() async {
        var purchased: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchased.insert(transaction.productID)
            }
        }
        purchasedProductIDs = purchased
    }
}
```

**Key behaviors:**
- Offline support via StoreKit 2 caching
- Auto-renewal handling by Apple
- Family Sharing included automatically
- Grace period during billing retry

## Paywall Strategy

**Onboarding upsell (first launch only):**
1. Page 1: Unlimited Trips benefit
2. Page 2: Export & Share benefit
3. Page 3: Track Your Journey benefit
4. Page 4: Pricing with annual pre-selected
5. "Continue with Free" de-emphasized

**Soft gates throughout app:**
- ProBadge: Small "PRO" capsule on locked features
- ProGate modifier: Wraps Pro-only actions, presents PaywallView on tap

```swift
Button("Export PDF") { exportPDF() }
    .proGate(feature: .pdfExport)

ProgressTabView()
    .proGate(feature: .progressTracking)
```

**Trip save gating:**
```swift
func saveTripTapped() {
    if !StoreManager.shared.isPro && savedTripCount >= 1 {
        showPaywall = true
    } else {
        saveTrip()
    }
}
```

## Transaction Handling

**Purchase flow:**
```swift
func purchase(_ product: Product) async throws {
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await transaction.finish()  // Always finish
        await updateEntitlements()

    case .userCancelled:
        break

    case .pending:
        break  // Ask to Buy or SCA

    @unknown default:
        break
    }
}
```

**Transaction listener (app lifetime):**
```swift
func listenForTransactions() -> Task<Void, Never> {
    Task.detached {
        for await result in Transaction.updates {
            if case .verified(let transaction) = result {
                await transaction.finish()
                await StoreManager.shared.updateEntitlements()
            }
        }
    }
}
```

**Restore purchases:**
```swift
func restorePurchases() async {
    await AppStore.sync()  // Sync from App Store
    await updateEntitlements()
}
```

## File Structure

**New files:**
```
SportsTime/
├── Core/
│   └── Store/
│       ├── StoreManager.swift
│       ├── ProFeature.swift
│       └── StoreError.swift
├── Features/
│   └── Paywall/
│       ├── Views/
│       │   ├── PaywallView.swift
│       │   ├── OnboardingPaywallView.swift
│       │   └── ProBadge.swift
│       └── ViewModifiers/
│           └── ProGate.swift
└── SportsTime.storekit
```

**Integration points:**

| File | Change |
|------|--------|
| `SportsTimeApp.swift` | Initialize StoreManager, start transaction listener |
| `TripDetailView.swift` | Gate save button when trip limit reached |
| `HomeView.swift` | Show trip count, badge if at limit |
| `ProgressTabView.swift` | Wrap with `.proGate(feature: .progressTracking)` |
| `ExportService.swift` | Check `isPro` before allowing PDF export |
| `SettingsView.swift` | Add "Manage Subscription" row, restore purchases |

## Testing

**StoreKit Configuration File (`SportsTime.storekit`):**
- Monthly subscription: $4.99, 1 month duration
- Annual subscription: $49.99, 1 year duration
- Subscription group: "Pro Access"

**Test scenarios:**

| Scenario | How to test |
|----------|-------------|
| New user (no purchase) | Fresh simulator, verify `isPro = false` |
| Purchase monthly | Buy in simulator, verify immediate access |
| Purchase annual | Buy in simulator, verify immediate access |
| Subscription expiration | StoreKit config time controls |
| Restore on new device | Delete app, reinstall, tap Restore |
| Family Sharing | Enable in StoreKit config |
| Offline mode | Airplane mode, verify `isPro` works |
| Ask to Buy (pending) | Enable in StoreKit config |

**Scheme setup:**
Edit scheme → Run → Options → StoreKit Configuration → Select `SportsTime.storekit`

## App Store Connect Setup (Manual)

1. Create subscription group "Pro Access"
2. Add monthly product: `com.sportstime.pro.monthly` at $4.99
3. Add annual product: `com.sportstime.pro.annual` at $49.99
4. Enable Family Sharing for both products
5. Configure subscription metadata and localization
