From 56138d3282363804a8f3d924d9d498064953a010 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 10:07:08 -0600 Subject: [PATCH] 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 --- .../2026-01-13-in-app-purchase-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/plans/2026-01-13-in-app-purchase-design.md diff --git a/docs/plans/2026-01-13-in-app-purchase-design.md b/docs/plans/2026-01-13-in-app-purchase-design.md new file mode 100644 index 0000000..a925b56 --- /dev/null +++ b/docs/plans/2026-01-13-in-app-purchase-design.md @@ -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 = [] + + var isPro: Bool { + !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty + } + + private static let proProductIDs: Set = [ + "com.sportstime.pro.monthly", + "com.sportstime.pro.annual" + ] + + func updateEntitlements() async { + var purchased: Set = [] + 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 { + 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