Simran Preet Singh

I am a fullstack developer. If you give me one more task, my stack will overflow.
✍🏽 iOS | Python | SQL

Adding Subscription with Revenue Cat | Simran Preet Singh

Adding Subscription with Revenue Cat

December 01, 2024

Complete RevenueCat SwiftUI Subscription Implementation Guide for Invoice Apps

RevenueCat has revolutionized subscription management for iOS apps by providing server-side receipt validation, cross-platform analytics, and seamless StoreKit 2 integration. This comprehensive guide covers everything needed to implement a production-ready subscription system in 2024-2025, specifically tailored for invoice creation apps with monthly and yearly tiers.

1. Complete RevenueCat Setup Process

Critical Prerequisites - Must Complete First

Before any other setup, these steps are mandatory in App Store Connect or you’ll encounter “Could not get offerings” errors:

  1. Sign Paid Applications Agreement
    • Navigate to App Store Connect → Agreements, Tax, and Banking
    • Sign the latest Paid Applications Agreement
    • Status must show “Active”
  2. Complete Tax Information
    • Fill out all required tax forms in the “Tax” section
    • Submit and verify all tax documents (can take 24-48 hours)
  3. Add Banking Information
    • Link a valid bank account in the “Banking” section
    • Status must show “Clear” (processing takes 24-48 hours)

RevenueCat Account Setup

Create and configure your RevenueCat project:

# 1. Sign up at revenuecat.com
# 2. Create new project
# 3. Add iOS app with your bundle identifier

Project configuration steps:

App Store Connect Integration

Generate App Store Connect API Key:

  1. App Store Connect → Users and Access → Integrations → App Store Connect API
  2. Click “+” to create new key with “App Manager” access level
  3. Download the .p8 key file (only downloadable once - store securely)
  4. Note the Issuer ID (shown above the Active table)

Upload to RevenueCat:

// In RevenueCat dashboard:
// Project Settings → Apps → [Your App] → App Store Connect API tab
// Upload .p8 file, enter Issuer ID and Vendor Number

App-Specific Shared Secret:

// Generate in App Store Connect:
// Your app → App Information → App-Specific Shared Secret → Manage → Generate
// Add to RevenueCat app settings

2. iOS Project Setup

// Add Package Dependencies in Xcode:
// URL: https://github.com/RevenueCat/purchases-ios-spm.git
// Version: Up to next major 5.0.0 < 6.0.0

Alternative installations:

# CocoaPods
pod 'RevenueCat', '~> 5.0'
pod 'RevenueCatUI', '~> 5.0'  # Optional for pre-built paywalls

# Carthage
github "RevenueCat/purchases-ios"

Xcode Configuration

Enable In-App Purchase capability:

  1. Project settings → Signing & Capabilities
  2. Add “In-App Purchase” capability
  3. Verify bundle identifier matches App Store Connect

App initialization:

import SwiftUI
import RevenueCat

@main
struct InvoiceApp: App {
    init() {
        Purchases.logLevel = .debug  // Development only
        Purchases.configure(withAPIKey: "appl_your_public_api_key")
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(SubscriptionManager.shared)
        }
    }
}

App Store Connect Product Setup

Create subscription group:

  1. My Apps → [Your App] → Features → Subscriptions
  2. Create Subscription Group with reference name “Invoice Pro Subscriptions”

Add subscription products:

// Recommended Product IDs:
// invoice_pro_monthly_7d_free
// invoice_pro_yearly_7d_free

// For each product:
// - Reference Name: "Invoice Pro Monthly" or "Invoice Pro Annual"
// - Subscription Duration: 1 Month or 1 Year
// - Price: Select appropriate tier
// - Introductory Offer: 7-day free trial (recommended)

Required product configuration:

3. SwiftUI Code Implementation

Subscription Manager - Core State Management

import Foundation
import SwiftUI
import RevenueCat

@MainActor
class SubscriptionManager: NSObject, ObservableObject {
    static let shared = SubscriptionManager()
    
    @Published var customerInfo: CustomerInfo? {
        didSet { updateSubscriptionStatus() }
    }
    
    @Published var isSubscriptionActive = false
    @Published var currentOffering: Offering?
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    // Invoice app specific properties
    @Published var monthlyInvoiceLimit = 5
    @Published var currentInvoiceCount = 0
    @Published var canCreateCustomTemplates = false
    @Published var hasPaymentIntegration = false
    
    private let entitlementIdentifier = "invoice_pro"
    
    private override init() {
        super.init()
        Task { await initialize() }
    }
    
    private func initialize() async {
        // Listen to customer info updates using AsyncSequence
        Task {
            for await customerInfo in Purchases.shared.customerInfoStream {
                await MainActor.run {
                    self.customerInfo = customerInfo
                }
            }
        }
        
        await fetchInitialData()
    }
    
    private func fetchInitialData() async {
        do {
            async let customerInfoTask = Purchases.shared.customerInfo()
            async let offeringsTask = Purchases.shared.offerings()
            
            let (customerInfo, offerings) = try await (customerInfoTask, offeringsTask)
            
            await MainActor.run {
                self.customerInfo = customerInfo
                self.currentOffering = offerings.current
            }
        } catch {
            await MainActor.run {
                self.errorMessage = error.localizedDescription
            }
        }
    }
    
    private func updateSubscriptionStatus() {
        let hasEntitlement = customerInfo?.entitlements[entitlementIdentifier]?.isActive == true
        isSubscriptionActive = hasEntitlement
        
        // Update invoice app specific limits
        if hasEntitlement {
            monthlyInvoiceLimit = -1 // Unlimited
            canCreateCustomTemplates = true
            hasPaymentIntegration = true
        } else {
            monthlyInvoiceLimit = 5
            canCreateCustomTemplates = false
            hasPaymentIntegration = false
        }
    }
    
    // MARK: - Purchase Methods
    
    func purchase(package: Package) async throws -> CustomerInfo {
        await MainActor.run {
            isLoading = true
            errorMessage = nil
        }
        
        defer {
            Task { @MainActor in isLoading = false }
        }
        
        do {
            let result = try await Purchases.shared.purchase(package: package)
            await MainActor.run {
                self.customerInfo = result.customerInfo
            }
            return result.customerInfo
        } catch {
            await MainActor.run {
                self.errorMessage = handlePurchaseError(error)
            }
            throw error
        }
    }
    
    func restorePurchases() async throws -> CustomerInfo {
        await MainActor.run {
            isLoading = true
            errorMessage = nil
        }
        
        defer {
            Task { @MainActor in isLoading = false }
        }
        
        do {
            let customerInfo = try await Purchases.shared.restorePurchases()
            await MainActor.run {
                self.customerInfo = customerInfo
            }
            return customerInfo
        } catch {
            await MainActor.run {
                self.errorMessage = error.localizedDescription
            }
            throw error
        }
    }
    
    private func handlePurchaseError(_ error: Error) -> String {
        guard let rcError = error as? RevenueCat.ErrorCode else {
            return error.localizedDescription
        }
        
        switch rcError.code {
        case .purchaseCancelledError:
            return "Purchase was cancelled"
        case .networkError:
            return "Network error. Please try again."
        case .storeProblemError:
            return "Processing your purchase. You'll receive confirmation when complete."
        case .configurationError:
            return "There's an issue with the app configuration. Please contact support."
        case .receiptAlreadyInUseError:
            return "This purchase is already associated with another account"
        default:
            return "An unexpected error occurred. Please try again."
        }
    }
}

// MARK: - Invoice App Specific Methods
extension SubscriptionManager {
    func canCreateInvoice() -> Bool {
        return monthlyInvoiceLimit == -1 || currentInvoiceCount < monthlyInvoiceLimit
    }
    
    func incrementInvoiceCount() {
        guard monthlyInvoiceLimit != -1 else { return }
        currentInvoiceCount += 1
    }
    
    func getRemainingInvoices() -> Int {
        guard monthlyInvoiceLimit != -1 else { return -1 }
        return max(0, monthlyInvoiceLimit - currentInvoiceCount)
    }
}

Custom Paywall View for Invoice Apps

import SwiftUI
import RevenueCat

struct InvoiceSubscriptionPaywallView: View {
    @EnvironmentObject var subscriptionManager: SubscriptionManager
    @Environment(\.dismiss) private var dismiss
    @State private var selectedPackage: Package?
    
    private let features = [
        FeatureBenefit(
            icon: "doc.text.below.ecg",
            title: "Unlimited Invoices",
            description: "Create as many professional invoices as you need"
        ),
        FeatureBenefit(
            icon: "paintbrush.pointed",
            title: "Custom Templates",
            description: "Brand your invoices with custom logos and colors"
        ),
        FeatureBenefit(
            icon: "creditcard",
            title: "Payment Integration",
            description: "Get paid faster with Stripe and PayPal integration"
        ),
        FeatureBenefit(
            icon: "chart.bar.xaxis",
            title: "Financial Reports",
            description: "Track your income and expenses with detailed reports"
        )
    ]
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 32) {
                    // Header Section
                    VStack(spacing: 16) {
                        Image(systemName: "doc.text.fill")
                            .font(.system(size: 60))
                            .foregroundColor(.blue)
                        
                        Text("Unlock Professional Invoicing")
                            .font(.title)
                            .fontWeight(.bold)
                            .multilineTextAlignment(.center)
                        
                        Text("Get paid faster with unlimited invoices and professional features")
                            .font(.body)
                            .foregroundColor(.secondary)
                            .multilineTextAlignment(.center)
                    }
                    .padding(.top)
                    
                    // Features Section
                    LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
                        ForEach(features, id: \.title) { feature in
                            FeatureCard(feature: feature)
                        }
                    }
                    .padding(.horizontal)
                    
                    // Pricing Section
                    if let offering = subscriptionManager.currentOffering {
                        VStack(spacing: 12) {
                            Text("Choose Your Plan")
                                .font(.headline)
                                .fontWeight(.semibold)
                            
                            ForEach(offering.availablePackages, id: \.identifier) { package in
                                PackageSelectionView(
                                    package: package,
                                    isSelected: selectedPackage?.identifier == package.identifier
                                ) {
                                    selectedPackage = package
                                }
                            }
                        }
                        .padding(.horizontal)
                    }
                    
                    // Purchase Button
                    VStack(spacing: 16) {
                        AsyncButton(
                            action: { await purchaseSelected() },
                            isLoading: subscriptionManager.isLoading
                        ) {
                            HStack {
                                if subscriptionManager.isLoading {
                                    ProgressView()
                                        .scaleEffect(0.8)
                                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                                }
                                Text(selectedPackage != nil ? "Start Free Trial" : "Select a Plan")
                                    .font(.headline)
                                    .foregroundColor(.white)
                            }
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(selectedPackage != nil ? Color.blue : Color.gray)
                            .cornerRadius(12)
                        }
                        .disabled(selectedPackage == nil)
                        
                        // Restore Purchases
                        Button("Restore Purchases") {
                            Task {
                                try? await subscriptionManager.restorePurchases()
                            }
                        }
                        .font(.caption)
                        .foregroundColor(.secondary)
                    }
                    .padding(.horizontal)
                    
                    // Terms and Privacy
                    HStack(spacing: 16) {
                        Link("Terms of Service", destination: URL(string: "https://yourapp.com/terms")!)
                        Link("Privacy Policy", destination: URL(string: "https://yourapp.com/privacy")!)
                    }
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .padding(.bottom)
                }
            }
            .navigationTitle("Invoice Pro")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Close") { dismiss() }
                }
            }
        }
        .alert("Error", isPresented: .constant(subscriptionManager.errorMessage != nil)) {
            Button("OK") {
                subscriptionManager.errorMessage = nil
            }
        } message: {
            Text(subscriptionManager.errorMessage ?? "")
        }
        .task {
            if subscriptionManager.currentOffering == nil {
                await subscriptionManager.fetchInitialData()
            }
        }
    }
    
    private func purchaseSelected() async {
        guard let package = selectedPackage else { return }
        
        do {
            _ = try await subscriptionManager.purchase(package: package)
            dismiss()
        } catch {
            // Error handling is managed in SubscriptionManager
        }
    }
}

// MARK: - Supporting Views

struct FeatureBenefit {
    let icon: String
    let title: String
    let description: String
}

struct FeatureCard: View {
    let feature: FeatureBenefit
    
    var body: some View {
        VStack(spacing: 12) {
            Image(systemName: feature.icon)
                .font(.system(size: 32))
                .foregroundColor(.blue)
            
            VStack(spacing: 4) {
                Text(feature.title)
                    .font(.headline)
                    .fontWeight(.semibold)
                    .multilineTextAlignment(.center)
                
                Text(feature.description)
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .multilineTextAlignment(.center)
            }
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }
}

struct PackageSelectionView: View {
    let package: Package
    let isSelected: Bool
    let onTap: () -> Void
    
    var body: some View {
        Button(action: onTap) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Text(package.storeProduct.displayName)
                            .font(.headline)
                            .foregroundColor(.primary)
                        
                        if package.packageType == .annual {
                            Text("SAVE 50%")
                                .font(.caption2)
                                .fontWeight(.bold)
                                .foregroundColor(.white)
                                .padding(.horizontal, 8)
                                .padding(.vertical, 2)
                                .background(Color.orange)
                                .cornerRadius(4)
                        }
                        
                        Spacer()
                    }
                    
                    if let introPrice = package.storeProduct.introductoryDiscount {
                        Text("Free trial: \(introPrice.subscriptionPeriod.localizedDescription)")
                            .font(.caption)
                            .foregroundColor(.green)
                    }
                    
                    Text(package.storeProduct.displayPrice)
                        .font(.title2)
                        .fontWeight(.bold)
                        .foregroundColor(.primary)
                }
                
                Spacer()
                
                Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
                    .font(.title2)
                    .foregroundColor(isSelected ? .blue : .gray)
            }
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color(.systemBackground))
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .stroke(isSelected ? Color.blue : Color(.systemGray4), lineWidth: 2)
                    )
            )
        }
        .buttonStyle(PlainButtonStyle())
    }
}

struct AsyncButton<Label: View>: View {
    let action: () async -> Void
    let isLoading: Bool
    @ViewBuilder let label: () -> Label
    
    var body: some View {
        Button {
            Task { await action() }
        } label: {
            label()
        }
        .disabled(isLoading)
    }
}

Content View with Invoice-Specific Feature Gates

struct ContentView: View {
    @EnvironmentObject var subscriptionManager: SubscriptionManager
    @State private var showingPaywall = false
    @State private var showingInvoiceCreation = false
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                // Invoice Status Card
                invoiceStatusCard
                
                // Main Actions
                VStack(spacing: 16) {
                    createInvoiceButton
                    
                    if subscriptionManager.isSubscriptionActive {
                        premiumFeatureButtons
                    } else {
                        upgradePromptCard
                    }
                }
                
                Spacer()
            }
            .padding()
            .navigationTitle("Invoice Creator")
            .sheet(isPresented: $showingPaywall) {
                InvoiceSubscriptionPaywallView()
            }
            .sheet(isPresented: $showingInvoiceCreation) {
                InvoiceCreationView()
            }
            .onChange(of: scenePhase) { phase in
                if phase == .active {
                    Task {
                        await subscriptionManager.fetchInitialData()
                    }
                }
            }
        }
    }
    
    private var invoiceStatusCard: some View {
        VStack(spacing: 12) {
            HStack {
                VStack(alignment: .leading) {
                    Text("Invoice Status")
                        .font(.headline)
                        .fontWeight(.semibold)
                    
                    if subscriptionManager.isSubscriptionActive {
                        Text("Pro Account - Unlimited")
                            .font(.subheadline)
                            .foregroundColor(.green)
                    } else {
                        Text("Free Account")
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                        
                        let remaining = subscriptionManager.getRemainingInvoices()
                        Text("\(remaining) invoices remaining this month")
                            .font(.caption)
                            .foregroundColor(remaining > 0 ? .primary : .red)
                    }
                }
                
                Spacer()
                
                Image(systemName: subscriptionManager.isSubscriptionActive ? "crown.fill" : "doc.text")
                    .font(.title2)
                    .foregroundColor(subscriptionManager.isSubscriptionActive ? .yellow : .gray)
            }
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }
    
    private var createInvoiceButton: some View {
        Button(action: createInvoiceAction) {
            HStack {
                Image(systemName: "plus.circle.fill")
                Text("Create New Invoice")
                Spacer()
            }
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .background(
                subscriptionManager.canCreateInvoice() ? Color.blue : Color.gray
            )
            .cornerRadius(12)
        }
        .disabled(!subscriptionManager.canCreateInvoice())
    }
    
    private var premiumFeatureButtons: some View {
        VStack(spacing: 12) {
            Button("Custom Templates") {
                // Navigate to custom templates
            }
            .buttonStyle(SecondaryButtonStyle())
            
            Button("Payment Integration Setup") {
                // Navigate to payment setup
            }
            .buttonStyle(SecondaryButtonStyle())
            
            Button("Financial Reports") {
                // Navigate to reports
            }
            .buttonStyle(SecondaryButtonStyle())
        }
    }
    
    private var upgradePromptCard: some View {
        VStack(spacing: 12) {
            Text("Unlock Professional Features")
                .font(.headline)
                .fontWeight(.semibold)
            
            Text("Get unlimited invoices, custom branding, and payment integration")
                .font(.subheadline)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
            
            Button("Upgrade to Pro") {
                showingPaywall = true
            }
            .buttonStyle(PrimaryButtonStyle())
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }
    
    private func createInvoiceAction() {
        if subscriptionManager.canCreateInvoice() {
            showingInvoiceCreation = true
            subscriptionManager.incrementInvoiceCount()
        } else {
            showingPaywall = true
        }
    }
}

// MARK: - Button Styles

struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundColor(.white)
            .padding()
            .frame(maxWidth: .infinity)
            .background(Color.blue)
            .cornerRadius(12)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
    }
}

struct SecondaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.subheadline)
            .foregroundColor(.blue)
            .padding()
            .frame(maxWidth: .infinity)
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
    }
}

4. Product Configuration in RevenueCat Dashboard

Entitlements Setup

Create the main entitlement:

// RevenueCat Dashboard → Product catalog → Entitlements
// Identifier: "invoice_pro"
// Description: "Professional invoice creation features"

Products configuration:

// Product catalog → Products
// Product 1:
// - Identifier: "invoice_pro_monthly_7d_free"
// - Store: App Store
// - Type: Auto-Renewable Subscription

// Product 2:
// - Identifier: "invoice_pro_yearly_7d_free"
// - Store: App Store
// - Type: Auto-Renewable Subscription

Offerings setup:

// Product catalog → Offerings
// Offering identifier: "invoice_pro_offering"
// Description: "Invoice Pro subscription options"

// Packages:
// - monthly: Maps to "invoice_pro_monthly_7d_free"
// - annual: Maps to "invoice_pro_yearly_7d_free"

5. Complete Error Handling Implementation

Production-Ready Error Management

// Enhanced error handling for production apps
extension SubscriptionManager {
    func handleComprehensiveError(_ error: Error) -> ErrorInfo {
        if let rcError = error as? RevenueCat.ErrorCode {
            return handleRevenueCatError(rcError)
        } else if let skError = error as? SKError {
            return handleStoreKitError(skError)
        } else {
            return ErrorInfo(
                title: "Unexpected Error",
                message: "An unexpected error occurred. Please try again.",
                actionTitle: "Retry",
                isRetryable: true
            )
        }
    }
    
    private func handleRevenueCatError(_ error: RevenueCat.ErrorCode) -> ErrorInfo {
        switch error.code {
        case .purchaseCancelledError:
            return ErrorInfo(
                title: "Purchase Cancelled",
                message: "The purchase was cancelled.",
                actionTitle: "OK",
                isRetryable: false
            )
            
        case .networkError:
            return ErrorInfo(
                title: "Network Error",
                message: "Please check your internet connection and try again.",
                actionTitle: "Retry",
                isRetryable: true
            )
            
        case .storeProblemError:
            return ErrorInfo(
                title: "Store Unavailable",
                message: "The App Store is temporarily unavailable. Your purchase is being processed.",
                actionTitle: "OK",
                isRetryable: false
            )
            
        case .configurationError:
            return ErrorInfo(
                title: "Configuration Error",
                message: "There's an issue with the app setup. Please contact support.",
                actionTitle: "Contact Support",
                isRetryable: false
            )
            
        case .receiptAlreadyInUseError:
            return ErrorInfo(
                title: "Account Conflict",
                message: "This purchase is associated with another account. Try restoring purchases or contact support.",
                actionTitle: "Restore",
                isRetryable: true
            )
            
        default:
            return ErrorInfo(
                title: "Purchase Error",
                message: "Unable to complete purchase. Please try again.",
                actionTitle: "Retry",
                isRetryable: true
            )
        }
    }
    
    private func handleStoreKitError(_ error: SKError) -> ErrorInfo {
        switch error.code {
        case .paymentCancelled:
            return ErrorInfo(
                title: "Payment Cancelled",
                message: "The payment was cancelled.",
                actionTitle: "OK",
                isRetryable: false
            )
            
        case .paymentNotAllowed:
            return ErrorInfo(
                title: "Payment Not Allowed",
                message: "In-app purchases are disabled on this device.",
                actionTitle: "OK",
                isRetryable: false
            )
            
        case .clientInvalid:
            return ErrorInfo(
                title: "Payment Error",
                message: "The app is not configured for payments.",
                actionTitle: "Contact Support",
                isRetryable: false
            )
            
        default:
            return ErrorInfo(
                title: "Store Error",
                message: "Unable to process payment. Please try again.",
                actionTitle: "Retry",
                isRetryable: true
            )
        }
    }
}

struct ErrorInfo {
    let title: String
    let message: String
    let actionTitle: String
    let isRetryable: Bool
}

6. Testing Procedures and Validation

Comprehensive Testing Strategy

Sandbox Testing Setup:

#if DEBUG
// Enable debug logs for development
Purchases.logLevel = .debug

// Test with multiple sandbox accounts
// Create accounts in App Store Connect → Users and Access → Sandbox Testers
#endif

Testing checklist for each environment:

// Development Testing (Simulator + Device)
func testDevelopmentEnvironment() {
    // 1. Test product loading
    // 2. Test purchase flow interruption
    // 3. Test network failure scenarios
    // 4. Test subscription status checking
    // 5. Test restore purchases
}

// TestFlight Testing (Important: 24-hour renewal cycles as of Dec 2024)
func testTestFlightEnvironment() {
    // 1. Test complete purchase flows
    // 2. Test subscription renewal (now takes 24 hours)
    // 3. Test subscription management through iOS Settings
    // 4. Test cross-device synchronization
}

// Production Testing
func testProductionEnvironment() {
    // 1. Test real purchase with test account
    // 2. Verify analytics tracking
    // 3. Test subscription lifecycle
    // 4. Validate receipt handling
}

Automated testing implementation:

import XCTest
@testable import InvoiceApp

class SubscriptionTests: XCTestCase {
    var subscriptionManager: SubscriptionManager!
    
    override func setUp() {
        super.setUp()
        subscriptionManager = SubscriptionManager()
    }
    
    func testSubscriptionStatusUpdates() {
        // Mock customer info with active subscription
        let mockCustomerInfo = createMockCustomerInfo(hasActiveSubscription: true)
        
        // Update subscription manager
        subscriptionManager.customerInfo = mockCustomerInfo
        
        // Verify feature access
        XCTAssertTrue(subscriptionManager.isSubscriptionActive)
        XCTAssertTrue(subscriptionManager.canCreateCustomTemplates)
        XCTAssertEqual(subscriptionManager.monthlyInvoiceLimit, -1)
    }
    
    func testInvoiceLimitEnforcement() {
        // Test free account limits
        let freeCustomerInfo = createMockCustomerInfo(hasActiveSubscription: false)
        subscriptionManager.customerInfo = freeCustomerInfo
        
        // Test invoice creation limits
        XCTAssertEqual(subscriptionManager.monthlyInvoiceLimit, 5)
        XCTAssertTrue(subscriptionManager.canCreateInvoice())
        
        // Simulate reaching limit
        subscriptionManager.currentInvoiceCount = 5
        XCTAssertFalse(subscriptionManager.canCreateInvoice())
    }
}

7. SwiftUI Best Practices for Subscription UI

Accessibility Implementation

struct AccessibleSubscriptionView: View {
    @EnvironmentObject var subscriptionManager: SubscriptionManager
    
    var body: some View {
        VStack {
            // Accessible pricing display
            Text("Premium Plan")
                .font(.title2)
                .accessibilityLabel("Premium subscription plan")
            
            Text("$9.99/month")
                .font(.title)
                .accessibilityLabel("Nine dollars and ninety-nine cents per month")
            
            Button("Subscribe Now") {
                // Purchase action
            }
            .accessibilityLabel("Subscribe to premium plan")
            .accessibilityHint("Starts subscription with 7-day free trial")
            .accessibilityAddTraits(.isButton)
        }
        .dynamicTypeSize(.xSmall ... .accessibility3)
    }
}

Performance Optimization

// Efficient subscription status checking
extension SubscriptionManager {
    private func optimizedStatusCheck() async {
        // Cache subscription status to avoid excessive API calls
        let cacheKey = "subscription_status_cache"
        let cacheExpiry = UserDefaults.standard.object(forKey: "\(cacheKey)_expiry") as? Date
        
        if let expiry = cacheExpiry, expiry > Date() {
            // Use cached status
            return
        }
        
        // Fetch fresh status
        await fetchInitialData()
        
        // Cache for 5 minutes
        UserDefaults.standard.set(Date().addingTimeInterval(300), forKey: "\(cacheKey)_expiry")
    }
}

8. Invoice App-Specific Integration Patterns

Feature Gating Implementation

// Invoice-specific feature gates
class InvoiceFeatureGate {
    private let subscriptionManager: SubscriptionManager
    
    init(subscriptionManager: SubscriptionManager) {
        self.subscriptionManager = subscriptionManager
    }
    
    func canAccessFeature(_ feature: InvoiceFeature) -> Bool {
        switch feature {
        case .unlimitedInvoices:
            return subscriptionManager.isSubscriptionActive
        case .customTemplates:
            return subscriptionManager.canCreateCustomTemplates
        case .paymentIntegration:
            return subscriptionManager.hasPaymentIntegration
        case .financialReports:
            return subscriptionManager.isSubscriptionActive
        case .recurringBilling:
            return subscriptionManager.isSubscriptionActive
        }
    }
    
    func getUpgradePrompt(for feature: InvoiceFeature) -> UpgradePrompt {
        switch feature {
        case .unlimitedInvoices:
            return UpgradePrompt(
                title: "Unlimited Invoices",
                message: "Create unlimited professional invoices",
                benefits: ["No monthly limits", "Professional templates", "Priority support"]
            )
        case .customTemplates:
            return UpgradePrompt(
                title: "Custom Branding",
                message: "Make your invoices uniquely yours",
                benefits: ["Custom logos", "Brand colors", "Professional appearance"]
            )
        default:
            return UpgradePrompt(
                title: "Pro Features",
                message: "Unlock all professional features",
                benefits: ["All premium features", "Priority support", "Advanced integrations"]
            )
        }
    }
}

enum InvoiceFeature {
    case unlimitedInvoices
    case customTemplates
    case paymentIntegration
    case financialReports
    case recurringBilling
}

struct UpgradePrompt {
    let title: String
    let message: String
    let benefits: [String]
}

Usage Tracking and Analytics

// Invoice usage tracking for subscription optimization
class InvoiceAnalytics {
    private let subscriptionManager: SubscriptionManager
    
    func trackInvoiceCreation() {
        subscriptionManager.incrementInvoiceCount()
        
        // Track usage for conversion optimization
        if !subscriptionManager.isSubscriptionActive {
            let remaining = subscriptionManager.getRemainingInvoices()
            if remaining <= 2 {
                // Show upgrade prompt when limit is near
                NotificationCenter.default.post(name: .showUpgradePrompt, object: nil)
            }
        }
    }
    
    func trackFeatureAttempt(_ feature: InvoiceFeature) {
        let canAccess = InvoiceFeatureGate(subscriptionManager: subscriptionManager)
            .canAccessFeature(feature)
        
        if !canAccess {
            // Track conversion opportunities
            Analytics.track("feature_blocked", properties: [
                "feature": feature.rawValue,
                "user_type": subscriptionManager.isSubscriptionActive ? "premium" : "free"
            ])
            
            // Show contextual upgrade prompt
            NotificationCenter.default.post(
                name: .showFeatureUpgrade,
                object: feature
            )
        }
    }
}

extension Notification.Name {
    static let showUpgradePrompt = Notification.Name("showUpgradePrompt")
    static let showFeatureUpgrade = Notification.Name("showFeatureUpgrade")
}

9. Real-World Implementation Considerations

Production Deployment Checklist

Pre-launch validation:

// Production readiness checklist
struct ProductionChecklist {
    static func validate() -> [ValidationResult] {
        var results: [ValidationResult] = []
        
        // 1. API Key Configuration
        if Purchases.isConfigured {
            results.append(.success("RevenueCat configured"))
        } else {
            results.append(.error("RevenueCat not configured"))
        }
        
        // 2. Product Configuration
        if hasValidProducts() {
            results.append(.success("Products configured"))
        } else {
            results.append(.error("Products not found"))
        }
        
        // 3. Entitlements Setup
        if hasValidEntitlements() {
            results.append(.success("Entitlements configured"))
        } else {
            results.append(.error("Entitlements not configured"))
        }
        
        return results
    }
}

Common Gotchas and Solutions

Issue 1: StoreKit Configuration File Limitations

// StoreKit Configuration files only work when running directly through Xcode
// Command line tools (flutter run, react-native run-ios) won't use them
// Solution: Always test on device with real sandbox accounts

Issue 2: TestFlight Subscription Renewal Changes

// As of December 2024, TestFlight subscriptions renew every 24 hours
// This significantly slows subscription lifecycle testing
// Solution: Plan testing schedules accordingly and use sandbox for rapid testing

Issue 3: Certificate Expiration (January 2025)

// Apple's SHA-1 intermediate certificate expires January 24, 2025
// RevenueCat handles this automatically - no action required
// Apps using manual receipt validation must update to support SHA-256

Performance and Scalability

// Efficient subscription checking for large user bases
class OptimizedSubscriptionManager {
    private let cache = NSCache<NSString, CustomerInfo>()
    private let cacheExpiry: TimeInterval = 300 // 5 minutes
    
    func getCachedCustomerInfo(userId: String) async -> CustomerInfo? {
        let cacheKey = NSString(string: userId)
        
        if let cached = cache.object(forKey: cacheKey) {
            return cached
        }
        
        // Fetch fresh data
        do {
            let customerInfo = try await Purchases.shared.customerInfo()
            cache.setObject(customerInfo, forKey: cacheKey)
            return customerInfo
        } catch {
            return nil
        }
    }
}

This comprehensive guide provides everything needed to implement a production-ready RevenueCat subscription system in SwiftUI for invoice creation apps. The implementation covers all essential aspects from setup through production deployment, with particular attention to 2024-2025 best practices and common pitfalls.

Key success factors include thorough testing across all three environments (sandbox, TestFlight, production), proper error handling with user-friendly messaging, and strategic feature gating that encourages conversion without frustrating free users. The invoice app-specific patterns ensure optimal conversion rates by presenting upgrade prompts at natural friction points in the user workflow.