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>
6.8 KiB
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.monthlycom.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):
- Page 1: Unlimited Trips benefit
- Page 2: Export & Share benefit
- Page 3: Track Your Journey benefit
- Page 4: Pricing with annual pre-selected
- "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)
- Create subscription group "Pro Access"
- Add monthly product:
com.sportstime.pro.monthlyat $4.99 - Add annual product:
com.sportstime.pro.annualat $49.99 - Enable Family Sharing for both products
- Configure subscription metadata and localization