981 lines
27 KiB
Markdown
981 lines
27 KiB
Markdown
# 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<Void, Never>?
|
|
|
|
/// 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 affects the in-app purchase flow (Use Case 2), not onboarding (Use Case 1):**
|
|
|
|
| Use Case | Affected? | Why |
|
|
|----------|-----------|-----|
|
|
| Onboarding | No | Success callback explicitly completes onboarding and transitions to main app |
|
|
| In-App (Settings) | **Yes** | UI must detect the change to show unlocked features |
|
|
|
|
### Why In-App Purchases Don't Update the UI
|
|
|
|
1. User opens Settings (which has a `SettingsViewModel` with its own `PremiumManager`)
|
|
2. User taps a premium feature, triggering the paywall
|
|
3. User completes purchase successfully
|
|
4. Paywall dismisses, returning to Settings
|
|
5. `isPremiumUnlocked` still returns `false` in the UI even though purchase succeeded
|
|
|
|
**Technical reason:**
|
|
- `isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo`
|
|
- The cached customer info IS updated after purchase
|
|
- But SwiftUI doesn't 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
|
|
|
|
Always call `refreshPremiumStatus()` when the paywall sheet dismisses:
|
|
|
|
```swift
|
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
|
// Force UI to re-check premium status after paywall closes
|
|
// This handles both successful purchases and user cancellations
|
|
settings.refreshPremiumStatus()
|
|
}) {
|
|
PaywallPresenter()
|
|
}
|
|
```
|
|
|
|
**Why use `onDismiss` instead of the success callback?**
|
|
- `onDismiss` is called for all dismissal reasons (purchase, cancel, swipe down)
|
|
- It ensures the UI always reflects the current state
|
|
- Works for both onboarding and in-app contexts
|
|
|
|
### Complete Integration Example
|
|
|
|
Handling both onboarding and in-app purchase flows:
|
|
|
|
```swift
|
|
struct RootView: View {
|
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
|
@State private var settings = SettingsViewModel()
|
|
@State private var showPaywall = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if hasCompletedOnboarding {
|
|
MainAppView(settings: settings, showPaywall: $showPaywall)
|
|
} else {
|
|
OnboardingView(showPaywall: $showPaywall, onComplete: completeOnboarding)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showPaywall, onDismiss: {
|
|
// ALWAYS refresh after paywall closes (for in-app purchases)
|
|
settings.refreshPremiumStatus()
|
|
}) {
|
|
PaywallPresenter {
|
|
// Only for onboarding: complete it on successful purchase
|
|
if !hasCompletedOnboarding {
|
|
completeOnboarding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func completeOnboarding() {
|
|
withAnimation {
|
|
hasCompletedOnboarding = true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Alternative: Shared PremiumManager via Environment
|
|
|
|
Instead of each view model having its own `PremiumManager`, use a single shared instance:
|
|
|
|
```swift
|
|
@main
|
|
struct YourApp: App {
|
|
@State private var premiumManager = PremiumManager()
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
RootView()
|
|
.environment(premiumManager)
|
|
.task {
|
|
await premiumManager.checkSubscriptionStatus()
|
|
premiumManager.startListeningForCustomerInfoUpdates()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// In any view:
|
|
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.
|
|
|
|
### Testing Checklist
|
|
|
|
Test both purchase flows to ensure they work correctly:
|
|
|
|
**Onboarding Flow:**
|
|
- [ ] Skip to paywall step in onboarding
|
|
- [ ] Complete a purchase
|
|
- [ ] Verify: App transitions to main content immediately
|
|
- [ ] Verify: Premium features are unlocked
|
|
|
|
**In-App Purchase Flow:**
|
|
- [ ] Complete onboarding WITHOUT purchasing (tap "Maybe Later")
|
|
- [ ] Open Settings
|
|
- [ ] Tap a premium feature to trigger paywall
|
|
- [ ] Complete a purchase
|
|
- [ ] Verify: Paywall dismisses
|
|
- [ ] Verify: Settings shows premium features as unlocked
|
|
- [ ] Verify: No need to restart app or reopen Settings
|
|
|
|
**Restore Purchases:**
|
|
- [ ] Test restore from onboarding soft paywall
|
|
- [ ] Test restore from in-app paywall
|
|
- [ ] Verify: UI updates correctly after restore
|
|
|
|
---
|
|
|
|
## 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)
|