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>
225 lines
6.8 KiB
Markdown
225 lines
6.8 KiB
Markdown
# 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
|