Adding Subscription with Revenue Cat
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:
-
Sign Paid Applications Agreement
- Navigate to App Store Connect → Agreements, Tax, and Banking
- Sign the latest Paid Applications Agreement
- Status must show “Active”
-
Complete Tax Information
- Fill out all required tax forms in the “Tax” section
- Submit and verify all tax documents (can take 24-48 hours)
-
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:
- Project Settings → API keys (note your public API key)
- Add iOS app under Project Settings → Apps
- Configure team access under Project Settings → Collaborators
App Store Connect Integration
Generate App Store Connect API Key:
- App Store Connect → Users and Access → Integrations → App Store Connect API
- Click “+” to create new key with “App Manager” access level
- Download the .p8 key file (only downloadable once - store securely)
- 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
SDK Installation (Swift Package Manager - Recommended)
// 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:
- Project settings → Signing & Capabilities
- Add “In-App Purchase” capability
- 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:
- My Apps → [Your App] → Features → Subscriptions
- 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:
- Screenshot (640 x 920 minimum)
- Review information
- Submit for review
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.