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:
Trey t
2026-01-13 10:07:08 -06:00
parent c0f1645434
commit 56138d3282

View 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