# 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 = [] var isPro: Bool { !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty } private static let proProductIDs: Set = [ "com.sportstime.pro.monthly", "com.sportstime.pro.annual" ] func updateEntitlements() async { var purchased: Set = [] 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 { 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