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>
This commit is contained in:
224
docs/plans/2026-01-13-in-app-purchase-design.md
Normal file
224
docs/plans/2026-01-13-in-app-purchase-design.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user