Files
Sportstime/docs/plans/2026-01-13-in-app-purchase-design.md
Trey t 56138d3282 docs: add in-app purchase and subscription system design
Freemium model with StoreKit 2 local-only entitlement checking.
Pro features: unlimited trips, PDF export, progress tracking.
Monthly ($4.99) and annual ($49.99) pricing with Family Sharing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:07:08 -06:00

6.8 KiB

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:

@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
Button("Export PDF") { exportPDF() }
    .proGate(feature: .pdfExport)

ProgressTabView()
    .proGate(feature: .progressTracking)

Trip save gating:

func saveTripTapped() {
    if !StoreManager.shared.isPro && savedTripCount >= 1 {
        showPaywall = true
    } else {
        saveTrip()
    }
}

Transaction Handling

Purchase flow:

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):

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:

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