From 7fff295913a98f1a65748720707be1aa1f58099d Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 3 Feb 2026 15:05:33 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../UserInterfaceState.xcuserstate | Bin 38146 -> 38146 bytes .../Features/Camera/Views/ContentView.swift | 9 +- .../SettingsViewModel+Premium.swift | 3 - .../ViewModels/SettingsViewModel.swift | 22 +- docs/REVENUECAT_INTEGRATION_GUIDE.md | 916 ++++++++++++++++++ 5 files changed, 936 insertions(+), 14 deletions(-) create mode 100644 docs/REVENUECAT_INTEGRATION_GUIDE.md diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index 95d34f09bbfbe185a5b1b3165d8e4c2d2c790a9e..ef0de9f75d0f4a4e7600c1aae61dac8873a6d796 100644 GIT binary patch delta 37 tcmZo##niNlX~UOzRv-OfhRKtE#>+FBZk9}V#lq_AUBoQ9nIrX=J^&9h4Uhl; delta 37 tcmZo##niNlX~UOzR`1!{U$amC886RhxLGpc6$`8PmdYBx%^az}^Z_+~5840# diff --git a/SelfieCam/Features/Camera/Views/ContentView.swift b/SelfieCam/Features/Camera/Views/ContentView.swift index a3c7c8d..141dca3 100644 --- a/SelfieCam/Features/Camera/Views/ContentView.swift +++ b/SelfieCam/Features/Camera/Views/ContentView.swift @@ -11,7 +11,6 @@ import Bedrock struct ContentView: View { @State private var settings = SettingsViewModel() - @State private var premiumManager = PremiumManager() @State private var showSettings = false @State private var showPaywall = false @@ -112,10 +111,12 @@ struct ContentView: View { }) { SettingsView(viewModel: settings, showPaywall: $showPaywall) } - .sheet(isPresented: $showPaywall) { + .sheet(isPresented: $showPaywall, onDismiss: { + // Force refresh of premium status after paywall closes + // This ensures SettingsViewModel and UI see the updated premium status + settings.refreshPremiumStatus() + }) { PaywallPresenter() - // No callback needed - paywall auto-dismisses on success - // and premium status updates automatically via PremiumManager } } diff --git a/SelfieCam/Features/Settings/ViewModels/SettingsViewModel+Premium.swift b/SelfieCam/Features/Settings/ViewModels/SettingsViewModel+Premium.swift index c88c130..583c6b5 100644 --- a/SelfieCam/Features/Settings/ViewModels/SettingsViewModel+Premium.swift +++ b/SelfieCam/Features/Settings/ViewModels/SettingsViewModel+Premium.swift @@ -21,9 +21,6 @@ extension SettingsViewModel { let wasPremium = premiumManager.isDebugPremiumToggleEnabled premiumManager.isDebugPremiumToggleEnabled = newValue - // Update premium status for UI refresh - isPremiumUnlocked = premiumManager.isPremiumUnlocked - // Reset premium settings when toggling OFF if wasPremium && !newValue { resetPremiumSettingsToDefaults() diff --git a/SelfieCam/Features/Settings/ViewModels/SettingsViewModel.swift b/SelfieCam/Features/Settings/ViewModels/SettingsViewModel.swift index dec5ee1..0987c4b 100644 --- a/SelfieCam/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/SelfieCam/Features/Settings/ViewModels/SettingsViewModel.swift @@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable { // MARK: - Premium Status - /// Whether the user has premium access (stored property for proper UI updates) - private var _isPremiumUnlocked: Bool = false + /// Refresh token that changes when premium status needs to be re-evaluated + /// Incrementing this forces SwiftUI to re-compute isPremiumUnlocked + private var premiumRefreshToken: Int = 0 - /// Whether the user has premium access (observable for UI updates) + /// Whether the user has premium access (computed from PremiumManager) + /// This always reads the current status from RevenueCat's cached customer info var isPremiumUnlocked: Bool { - get { _isPremiumUnlocked } - set { _isPremiumUnlocked = newValue } + // Access refresh token to create observation dependency + _ = premiumRefreshToken + return premiumManager.isPremiumUnlocked + } + + /// Force SwiftUI to re-evaluate premium status + /// Call this after a purchase completes or when returning from paywall + func refreshPremiumStatus() { + premiumRefreshToken += 1 } // MARK: - Display Settings @@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable { // MARK: - Initialization init() { - // Initialize premium status - _isPremiumUnlocked = premiumManager.isPremiumUnlocked // CloudSyncManager handles syncing automatically + // Premium status is always computed from premiumManager.isPremiumUnlocked } // MARK: - Internal Methods diff --git a/docs/REVENUECAT_INTEGRATION_GUIDE.md b/docs/REVENUECAT_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..6af05f1 --- /dev/null +++ b/docs/REVENUECAT_INTEGRATION_GUIDE.md @@ -0,0 +1,916 @@ +# RevenueCat Integration Guide for iOS + +A comprehensive, reusable guide for integrating RevenueCat into iOS apps with SwiftUI. This guide covers the complete flow from SDK setup to production deployment. + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [SDK Installation](#sdk-installation) +4. [Configuration Architecture](#configuration-architecture) +5. [PremiumManager Implementation](#premiummanager-implementation) +6. [Paywall Implementation](#paywall-implementation) +7. [Event Handling](#event-handling) +8. [App Store Connect Setup](#app-store-connect-setup) +9. [RevenueCat Dashboard Setup](#revenuecat-dashboard-setup) +10. [Testing Strategies](#testing-strategies) +11. [Production Checklist](#production-checklist) +12. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +RevenueCat simplifies in-app purchases by: +- Handling receipt validation server-side +- Providing cross-platform subscription status +- Offering analytics (MRR, churn, LTV) +- Managing entitlements across multiple products +- Providing pre-built UI components (Paywalls, Customer Center) + +### Key Concepts + +| Concept | Description | +|---------|-------------| +| **Product** | An individual purchasable item (monthly, yearly, lifetime) | +| **Entitlement** | A feature or access level granted by one or more products | +| **Offering** | A collection of packages shown in a paywall | +| **Package** | A product wrapped with metadata for display | +| **Customer Info** | User's current subscription/purchase state | + +--- + +## Prerequisites + +- Apple Developer Program membership ($99/year) +- RevenueCat account (free up to $2,500/month revenue) +- Xcode 15.0+ +- iOS 15.0+ deployment target +- Swift 5.9+ + +--- + +## SDK Installation + +### Swift Package Manager (Recommended) + +1. In Xcode: **File** → **Add Package Dependencies** +2. Enter: `https://github.com/RevenueCat/purchases-ios-spm` +3. Select version (use latest stable) +4. Add both packages to your target: + - `RevenueCat` - Core SDK + - `RevenueCatUI` - Paywalls and Customer Center + +### Package.swift (for packages) + +```swift +dependencies: [ + .package(url: "https://github.com/RevenueCat/purchases-ios-spm", from: "5.0.0") +], +targets: [ + .target( + name: "YourApp", + dependencies: [ + .product(name: "RevenueCat", package: "purchases-ios-spm"), + .product(name: "RevenueCatUI", package: "purchases-ios-spm") + ] + ) +] +``` + +--- + +## Configuration Architecture + +### Secrets Management + +Create a secure configuration system that separates API keys from source code. + +#### Option A: Swift Secrets File (Simple) + +Create `Configuration/Secrets.swift`: + +```swift +// Secrets.swift +// DO NOT COMMIT THIS FILE TO VERSION CONTROL + +import Foundation + +enum Secrets { + /// RevenueCat API Key + /// - Debug: Use test/sandbox key (starts with "test_") + /// - Release: Use production key (starts with "appl_") + static let revenueCatAPIKey = "YOUR_API_KEY_HERE" +} +``` + +Create `Configuration/Secrets.swift.template` (committed): + +```swift +// Copy this file to Secrets.swift and add your actual API key +// Secrets.swift is gitignored + +import Foundation + +enum Secrets { + static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE" +} +``` + +Add to `.gitignore`: + +``` +**/Secrets.swift +``` + +#### Option B: Separate Debug/Release Keys (Recommended for Production) + +Create two secrets files: + +**`Secrets.debug.swift`** (for testing): +```swift +enum Secrets { + static let revenueCatAPIKey = "test_your_test_key" +} +``` + +**`Secrets.release.swift`** (for production): +```swift +enum Secrets { + static let revenueCatAPIKey = "appl_your_production_key" +} +``` + +Use build configurations or compiler flags to include the correct file. + +--- + +## PremiumManager Implementation + +Create a centralized manager for all RevenueCat interactions. + +### Complete Implementation + +```swift +import RevenueCat +import SwiftUI + +@MainActor +@Observable +final class PremiumManager { + + // MARK: - Published State + + /// Available packages for purchase + var availablePackages: [Package] = [] + + // MARK: - Configuration + + /// Entitlement identifier - must match RevenueCat dashboard + private let entitlementIdentifier = "pro" + + /// Task for listening to customer info updates + @ObservationIgnored private var customerInfoTask: Task? + + /// API key from secrets + private static var apiKey: String { + let key = Secrets.revenueCatAPIKey + guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else { + #if DEBUG + print("⚠️ [PremiumManager] RevenueCat API key not configured") + #endif + return "" + } + return key + } + + // MARK: - Debug Override (Optional) + + #if DEBUG + @AppStorage("debugPremiumEnabled") + @ObservationIgnored private var debugPremiumEnabled = false + + private var isDebugPremiumEnabled: Bool { + debugPremiumEnabled || ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1" + } + + var isDebugPremiumToggleEnabled: Bool { + get { debugPremiumEnabled } + set { debugPremiumEnabled = newValue } + } + #endif + + // MARK: - Premium Status + + var isPremium: Bool { + #if DEBUG + if isDebugPremiumEnabled { return true } + #endif + + guard !Self.apiKey.isEmpty else { return false } + + return Purchases.shared.cachedCustomerInfo? + .entitlements[entitlementIdentifier]?.isActive == true + } + + /// Alias for compatibility + var isPremiumUnlocked: Bool { isPremium } + + // MARK: - Initialization + + init() { + guard !Self.apiKey.isEmpty else { + #if DEBUG + print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key") + #endif + return + } + + #if DEBUG + Purchases.logLevel = .debug + #endif + + Purchases.configure(withAPIKey: Self.apiKey) + + Task { + try? await loadProducts() + } + } + + // MARK: - Products + + func loadProducts() async throws { + guard !Self.apiKey.isEmpty else { return } + + let offerings = try await Purchases.shared.offerings() + if let current = offerings.current { + availablePackages = current.availablePackages + } + } + + // MARK: - Purchase + + func purchase(_ package: Package) async throws -> Bool { + #if DEBUG + if isDebugPremiumEnabled { + return true // Simulate success + } + #endif + + let result = try await Purchases.shared.purchase(package: package) + return result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true + } + + func purchase(productId: String) async throws { + guard let package = availablePackages.first(where: { + $0.storeProduct.productIdentifier == productId + }) else { + throw PurchaseError.productNotFound + } + _ = try await purchase(package) + } + + // MARK: - Restore + + func restorePurchases() async throws { + #if DEBUG + if isDebugPremiumEnabled { return } + #endif + + _ = try await Purchases.shared.restorePurchases() + } + + // MARK: - Subscription Status + + /// Fetch fresh customer info (call on app launch) + func checkSubscriptionStatus() async { + guard !Self.apiKey.isEmpty else { return } + + do { + _ = try await Purchases.shared.customerInfo() + } catch { + #if DEBUG + print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)") + #endif + } + } + + // MARK: - Real-time Updates + + /// Start listening for subscription changes + func startListeningForCustomerInfoUpdates() { + guard !Self.apiKey.isEmpty else { return } + + customerInfoTask?.cancel() + + customerInfoTask = Task { + for await customerInfo in Purchases.shared.customerInfoStream { + let isActive = customerInfo.entitlements[entitlementIdentifier]?.isActive == true + #if DEBUG + print("📱 [PremiumManager] Customer info updated. Premium: \(isActive)") + #endif + } + } + } + + func stopListeningForCustomerInfoUpdates() { + customerInfoTask?.cancel() + customerInfoTask = nil + } +} + +// MARK: - Errors + +enum PurchaseError: LocalizedError { + case productNotFound + + var errorDescription: String? { + switch self { + case .productNotFound: + return "Product not found" + } + } +} +``` + +### App Entry Point Integration + +```swift +@main +struct YourApp: App { + @State private var premiumManager = PremiumManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(premiumManager) + .task { + await premiumManager.checkSubscriptionStatus() + premiumManager.startListeningForCustomerInfoUpdates() + } + } + } +} +``` + +--- + +## Paywall Implementation + +### PaywallPresenter with Fallback + +Use RevenueCat's native PaywallView with a custom fallback for offline scenarios. + +```swift +import SwiftUI +import RevenueCat +import RevenueCatUI + +struct PaywallPresenter: View { + /// Called on successful purchase or restore + var onPurchaseSuccess: (() -> Void)? + + @Environment(\.dismiss) private var dismiss + @State private var offering: Offering? + @State private var isLoading = true + @State private var useFallback = false + @State private var errorMessage: String? + @State private var showError = false + + init(onPurchaseSuccess: (() -> Void)? = nil) { + self.onPurchaseSuccess = onPurchaseSuccess + } + + var body: some View { + Group { + if isLoading { + loadingView + } else if useFallback { + CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess) + } else if let offering { + paywallView(for: offering) + } else { + CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess) + } + } + .task { + await loadOffering() + } + .alert("Purchase Error", isPresented: $showError, presenting: errorMessage) { _ in + Button("OK", role: .cancel) { errorMessage = nil } + } message: { message in + Text(message) + } + } + + private var loadingView: some View { + VStack { + ProgressView() + .scaleEffect(1.5) + Text("Loading...") + .padding(.top, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func paywallView(for offering: Offering) -> some View { + PaywallView(offering: offering) + .onPurchaseCompleted { _ in + onPurchaseSuccess?() + dismiss() + } + .onPurchaseCancelled { + // User cancelled - keep paywall open + } + .onPurchaseFailure { error in + errorMessage = error.localizedDescription + showError = true + } + .onRestoreCompleted { customerInfo in + if !customerInfo.entitlements.active.isEmpty { + onPurchaseSuccess?() + dismiss() + } + } + .onRestoreFailure { error in + errorMessage = error.localizedDescription + showError = true + } + } + + private func loadOffering() async { + do { + let offerings = try await Purchases.shared.offerings() + if let current = offerings.current { + offering = current + } else { + useFallback = true + } + } catch { + useFallback = true + } + isLoading = false + } +} +``` + +### Paywall Integration Patterns + +There are two primary contexts for presenting paywalls, each requiring different handling. + +#### Use Case 1: Onboarding Flow + +During onboarding, a successful purchase should complete the onboarding and transition to the main app. + +```swift +struct RootView: View { + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + @State private var onboardingViewModel = OnboardingViewModel() + @State private var settingsViewModel = SettingsViewModel() + @State private var showPaywall = false + + var body: some View { + ZStack { + if hasCompletedOnboarding { + MainAppView() + } else { + OnboardingView( + viewModel: onboardingViewModel, + showPaywall: $showPaywall, + onComplete: { + withAnimation { + hasCompletedOnboarding = true + } + } + ) + } + } + .sheet(isPresented: $showPaywall) { + PaywallPresenter { + // Purchase successful during onboarding - complete it + if !hasCompletedOnboarding { + onboardingViewModel.completeOnboarding(settings: settingsViewModel) + withAnimation { + hasCompletedOnboarding = true + } + } + } + } + } +} +``` + +**Key points for onboarding:** +- Pass a success callback that completes onboarding +- Transition directly to main app after successful purchase +- User skipping the paywall continues onboarding normally + +#### Use Case 2: In-App Purchase (Settings, Feature Gates) + +When users trigger the paywall from within the app (e.g., tapping a premium feature in Settings), the UI must refresh to reflect the new premium status after purchase. + +```swift +struct MainContentView: View { + @State private var settings = SettingsViewModel() + @State private var showSettings = false + @State private var showPaywall = false + + var body: some View { + // Main app content + YourMainView() + .sheet(isPresented: $showSettings) { + SettingsView(viewModel: settings, showPaywall: $showPaywall) + } + .sheet(isPresented: $showPaywall, onDismiss: { + // CRITICAL: Force UI to refresh premium status after paywall closes + settings.refreshPremiumStatus() + }) { + PaywallPresenter() + } + } +} +``` + +**Key points for in-app purchases:** +- Use `onDismiss` to trigger a premium status refresh +- No success callback needed (UI refreshes via observation) +- Settings dismisses before showing paywall, then user returns to main view + +#### Combined Pattern + +For apps with both onboarding and in-app paywalls, handle both cases: + +```swift +.sheet(isPresented: $showPaywall, onDismiss: { + // Always refresh premium status when paywall closes + settings.refreshPremiumStatus() +}) { + PaywallPresenter { + // Only used during onboarding + if !hasCompletedOnboarding { + completeOnboarding() + } + // For in-app purchases, the onDismiss handles the refresh + } +} +``` + +--- + +## Event Handling + +### Available RevenueCat PaywallView Callbacks + +| Modifier | When Called | +|----------|------------| +| `.onPurchaseStarted { package in }` | Purchase initiated for a package | +| `.onPurchaseCompleted { customerInfo in }` | Purchase successful | +| `.onPurchaseCancelled { }` | User cancelled purchase | +| `.onPurchaseFailure { error in }` | Purchase failed | +| `.onRestoreStarted { }` | Restore initiated | +| `.onRestoreCompleted { customerInfo in }` | Restore successful | +| `.onRestoreFailure { error in }` | Restore failed | +| `.onRequestedDismissal { }` | Paywall requests to close | + +### Error Handling Best Practices + +```swift +.onPurchaseFailure { error in + let nsError = error as NSError + + // Don't show error for user cancellation + if nsError.code == 2 { // SKError.paymentCancelled + return + } + + // Handle specific error types + switch nsError.code { + case 0: // SKError.unknown + errorMessage = "An unknown error occurred. Please try again." + case 1: // SKError.clientInvalid + errorMessage = "You are not authorized to make purchases." + case 3: // SKError.paymentNotAllowed + errorMessage = "Payments are not allowed on this device." + default: + errorMessage = error.localizedDescription + } + showError = true +} +``` + +--- + +## SwiftUI Observation and Premium Status Updates + +### The Problem + +When a user completes a purchase, RevenueCat updates `Purchases.shared.cachedCustomerInfo` with the new subscription status. However, SwiftUI's `@Observable` macro doesn't automatically detect changes to this shared singleton. + +This causes a common bug: **after purchasing from a paywall triggered outside of onboarding (e.g., from Settings), the UI doesn't reflect the new premium status** even though the purchase succeeded. + +### Why It Happens + +1. Your `SettingsViewModel` (or similar) has a `PremiumManager` instance +2. `isPremiumUnlocked` is computed from `premiumManager.isPremiumUnlocked` +3. `PremiumManager.isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo` +4. After purchase, the cached customer info IS updated +5. But SwiftUI doesn't know to re-render because no `@Observable` property changed + +### Solution: Refresh Token Pattern + +Add a refresh mechanism that forces SwiftUI to re-evaluate the premium status: + +```swift +@MainActor +@Observable +final class SettingsViewModel { + @ObservationIgnored let premiumManager = PremiumManager() + + // Refresh token that forces re-evaluation when incremented + private var premiumRefreshToken: Int = 0 + + /// Premium status - always reads current value from RevenueCat + var isPremiumUnlocked: Bool { + // Access refresh token to create observation dependency + _ = premiumRefreshToken + return premiumManager.isPremiumUnlocked + } + + /// Force SwiftUI to re-evaluate premium status + /// Call this after paywall dismisses + func refreshPremiumStatus() { + premiumRefreshToken += 1 + } +} +``` + +### Triggering the Refresh + +Call `refreshPremiumStatus()` when the paywall sheet dismisses: + +```swift +struct ContentView: View { + @State private var settings = SettingsViewModel() + @State private var showPaywall = false + + var body: some View { + // ... your content ... + .sheet(isPresented: $showPaywall, onDismiss: { + // Force UI to re-check premium status after paywall closes + settings.refreshPremiumStatus() + }) { + PaywallPresenter() + } + } +} +``` + +### Alternative: Shared PremiumManager + +Instead of each view model having its own `PremiumManager`, you can use a single shared instance passed through the environment: + +```swift +@main +struct YourApp: App { + @State private var premiumManager = PremiumManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(premiumManager) + .task { + await premiumManager.checkSubscriptionStatus() + premiumManager.startListeningForCustomerInfoUpdates() + } + } + } +} + +// In views: +struct SettingsView: View { + @Environment(PremiumManager.self) private var premiumManager + + var body: some View { + if premiumManager.isPremiumUnlocked { + // Premium content + } + } +} +``` + +This approach ensures all views observe the same instance and receive updates together. + +### Key Takeaways + +1. **Always refresh after paywall dismisses** - Use `onDismiss` on the sheet +2. **Use a refresh token** for computed properties that read from external sources +3. **Consider a shared instance** if premium status is checked in many places +4. **Test the full flow** - Skip onboarding, trigger paywall from Settings, complete purchase, verify UI updates + +--- + +## App Store Connect Setup + +### 1. Create Your App + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. **My Apps** → **+** → **New App** +3. Fill in bundle ID (must match Xcode exactly) + +### 2. Create Subscription Group + +1. In your app → **Subscriptions** +2. Click **+** next to "Subscription Groups" +3. Name it (e.g., "Pro Access") + +### 3. Create Products + +**Auto-Renewable Subscriptions** (monthly, yearly): + +| Field | Example Value | +|-------|--------------| +| Reference Name | Pro Monthly | +| Product ID | `com.company.app.pro.monthly` | +| Duration | 1 Month | +| Price | $2.99 | + +**Non-Consumable** (lifetime): + +| Field | Example Value | +|-------|--------------| +| Reference Name | Pro Lifetime | +| Product ID | `com.company.app.pro.lifetime` | +| Price | $39.99 | + +### 4. Agreements + +1. **Agreements, Tax, and Banking** → Accept **Paid Applications** +2. Add bank account and tax information + +--- + +## RevenueCat Dashboard Setup + +### 1. Create Project and App + +1. Create project at [RevenueCat](https://app.revenuecat.com) +2. Add iOS app with your bundle ID + +### 2. Connect to App Store + +1. Get App-Specific Shared Secret from App Store Connect +2. Add to RevenueCat: **Project Settings** → **Apps** → your app + +### 3. Create Products + +Add each product with IDs matching App Store Connect exactly. + +### 4. Create Entitlement + +1. **Entitlements** → **+ New** +2. Name: `pro` (or your identifier) +3. Attach all products that grant this entitlement + +### 5. Create Offering + +1. **Offerings** → Edit **default** +2. Add packages: + - `$rc_monthly` → monthly product + - `$rc_annual` → yearly product + - `$rc_lifetime` → lifetime product + +### 6. Design Paywall (Optional) + +1. In your offering, click **Add Paywall** +2. Use the visual editor to design +3. Configure colors, text, layout + +### 7. Get API Keys + +- **Test Key** (starts with `test_`): For development, free purchases +- **Production Key** (starts with `appl_`): For App Store builds + +--- + +## Testing Strategies + +### 1. Debug Premium Toggle (Fastest) + +For UI testing without purchase flow: + +```swift +#if DEBUG +Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled) +#endif +``` + +### 2. StoreKit Configuration File (Local) + +1. **File** → **New** → **StoreKit Configuration File** +2. Add products matching your IDs +3. **Edit Scheme** → **Run** → **Options** → Set StoreKit Configuration +4. Purchases are instant and free + +### 3. RevenueCat Test Mode (Integration) + +Use the test API key (`test_`): +- Purchases are free +- Transactions appear in RevenueCat dashboard +- Restore not available (returns cached info) + +### 4. Sandbox Testing (Full) + +1. Create Sandbox Tester in App Store Connect +2. Sign out of App Store on device +3. Make purchase, sign in with sandbox credentials +4. Verify in RevenueCat dashboard + +**Sandbox Renewal Times:** + +| Real | Sandbox | +|------|---------| +| 1 week | 3 min | +| 1 month | 5 min | +| 1 year | 1 hour | + +--- + +## Production Checklist + +### Code + +- [ ] PremiumManager configured with production API key +- [ ] Secrets file is gitignored +- [ ] Error handling for all purchase scenarios +- [ ] Loading states during purchases +- [ ] Restore purchases functionality +- [ ] Real-time subscription status updates + +### App Store Connect + +- [ ] App created with correct bundle ID +- [ ] All products created with localizations +- [ ] Subscription group configured +- [ ] Paid Apps agreement accepted +- [ ] Bank and tax info submitted + +### RevenueCat Dashboard + +- [ ] Production app created with bundle ID +- [ ] Shared secret configured +- [ ] All products added (IDs match exactly) +- [ ] Entitlement created with products attached +- [ ] Offering configured with packages +- [ ] (Optional) Paywall designed + +### Testing + +- [ ] Tested with StoreKit Configuration +- [ ] Tested with sandbox account +- [ ] Verified transactions in RevenueCat dashboard +- [ ] Tested restore purchases +- [ ] Tested subscription expiration/renewal + +--- + +## Troubleshooting + +### Products Not Loading + +1. Verify product IDs match exactly (case-sensitive) +2. Check API key is correct +3. Ensure shared secret is configured +4. Wait a few minutes after creating products + +### "Purchases has not been configured" + +- `Purchases.configure(withAPIKey:)` must be called before any other SDK methods +- Ensure it's called early in app lifecycle (App init or `@main`) + +### Sandbox Purchase Fails + +1. Sign out of regular App Store +2. Use sandbox credentials (not real Apple ID) +3. Check Paid Apps agreement is accepted +4. Ensure products have prices set + +### Entitlements Not Granting Access + +1. Verify entitlement identifier matches code exactly +2. Check products are attached to entitlement +3. Confirm packages are in the offering + +### RevenueCat Dashboard Empty + +1. Using test key shows in sandbox environment +2. Check bundle ID matches exactly +3. Wait 1-2 minutes for transactions to appear + +--- + +## Resources + +- [RevenueCat Documentation](https://docs.revenuecat.com) +- [RevenueCat iOS SDK Reference](https://sdk.revenuecat.com/ios/index.html) +- [App Store Connect Help](https://help.apple.com/app-store-connect/) +- [StoreKit Documentation](https://developer.apple.com/documentation/storekit)