Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-03 15:05:33 -06:00
parent a94404d93d
commit 7fff295913
5 changed files with 936 additions and 14 deletions

View File

@ -11,7 +11,6 @@ import Bedrock
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showSettings = false
@State private var showPaywall = false
@ -112,10 +111,12 @@ struct ContentView: View {
}) {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall) {
.sheet(isPresented: $showPaywall, onDismiss: {
// Force refresh of premium status after paywall closes
// This ensures SettingsViewModel and UI see the updated premium status
settings.refreshPremiumStatus()
}) {
PaywallPresenter()
// No callback needed - paywall auto-dismisses on success
// and premium status updates automatically via PremiumManager
}
}

View File

@ -21,9 +21,6 @@ extension SettingsViewModel {
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
premiumManager.isDebugPremiumToggleEnabled = newValue
// Update premium status for UI refresh
isPremiumUnlocked = premiumManager.isPremiumUnlocked
// Reset premium settings when toggling OFF
if wasPremium && !newValue {
resetPremiumSettingsToDefaults()

View File

@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable {
// MARK: - Premium Status
/// Whether the user has premium access (stored property for proper UI updates)
private var _isPremiumUnlocked: Bool = false
/// Refresh token that changes when premium status needs to be re-evaluated
/// Incrementing this forces SwiftUI to re-compute isPremiumUnlocked
private var premiumRefreshToken: Int = 0
/// Whether the user has premium access (observable for UI updates)
/// Whether the user has premium access (computed from PremiumManager)
/// This always reads the current status from RevenueCat's cached customer info
var isPremiumUnlocked: Bool {
get { _isPremiumUnlocked }
set { _isPremiumUnlocked = newValue }
// Access refresh token to create observation dependency
_ = premiumRefreshToken
return premiumManager.isPremiumUnlocked
}
/// Force SwiftUI to re-evaluate premium status
/// Call this after a purchase completes or when returning from paywall
func refreshPremiumStatus() {
premiumRefreshToken += 1
}
// MARK: - Display Settings
@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable {
// MARK: - Initialization
init() {
// Initialize premium status
_isPremiumUnlocked = premiumManager.isPremiumUnlocked
// CloudSyncManager handles syncing automatically
// Premium status is always computed from premiumManager.isPremiumUnlocked
}
// MARK: - Internal Methods

View File

@ -0,0 +1,916 @@
# RevenueCat Integration Guide for iOS
A comprehensive, reusable guide for integrating RevenueCat into iOS apps with SwiftUI. This guide covers the complete flow from SDK setup to production deployment.
## Table of Contents
1. [Overview](#overview)
2. [Prerequisites](#prerequisites)
3. [SDK Installation](#sdk-installation)
4. [Configuration Architecture](#configuration-architecture)
5. [PremiumManager Implementation](#premiummanager-implementation)
6. [Paywall Implementation](#paywall-implementation)
7. [Event Handling](#event-handling)
8. [App Store Connect Setup](#app-store-connect-setup)
9. [RevenueCat Dashboard Setup](#revenuecat-dashboard-setup)
10. [Testing Strategies](#testing-strategies)
11. [Production Checklist](#production-checklist)
12. [Troubleshooting](#troubleshooting)
---
## Overview
RevenueCat simplifies in-app purchases by:
- Handling receipt validation server-side
- Providing cross-platform subscription status
- Offering analytics (MRR, churn, LTV)
- Managing entitlements across multiple products
- Providing pre-built UI components (Paywalls, Customer Center)
### Key Concepts
| Concept | Description |
|---------|-------------|
| **Product** | An individual purchasable item (monthly, yearly, lifetime) |
| **Entitlement** | A feature or access level granted by one or more products |
| **Offering** | A collection of packages shown in a paywall |
| **Package** | A product wrapped with metadata for display |
| **Customer Info** | User's current subscription/purchase state |
---
## Prerequisites
- Apple Developer Program membership ($99/year)
- RevenueCat account (free up to $2,500/month revenue)
- Xcode 15.0+
- iOS 15.0+ deployment target
- Swift 5.9+
---
## SDK Installation
### Swift Package Manager (Recommended)
1. In Xcode: **File** → **Add Package Dependencies**
2. Enter: `https://github.com/RevenueCat/purchases-ios-spm`
3. Select version (use latest stable)
4. Add both packages to your target:
- `RevenueCat` - Core SDK
- `RevenueCatUI` - Paywalls and Customer Center
### Package.swift (for packages)
```swift
dependencies: [
.package(url: "https://github.com/RevenueCat/purchases-ios-spm", from: "5.0.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "RevenueCat", package: "purchases-ios-spm"),
.product(name: "RevenueCatUI", package: "purchases-ios-spm")
]
)
]
```
---
## Configuration Architecture
### Secrets Management
Create a secure configuration system that separates API keys from source code.
#### Option A: Swift Secrets File (Simple)
Create `Configuration/Secrets.swift`:
```swift
// Secrets.swift
// DO NOT COMMIT THIS FILE TO VERSION CONTROL
import Foundation
enum Secrets {
/// RevenueCat API Key
/// - Debug: Use test/sandbox key (starts with "test_")
/// - Release: Use production key (starts with "appl_")
static let revenueCatAPIKey = "YOUR_API_KEY_HERE"
}
```
Create `Configuration/Secrets.swift.template` (committed):
```swift
// Copy this file to Secrets.swift and add your actual API key
// Secrets.swift is gitignored
import Foundation
enum Secrets {
static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE"
}
```
Add to `.gitignore`:
```
**/Secrets.swift
```
#### Option B: Separate Debug/Release Keys (Recommended for Production)
Create two secrets files:
**`Secrets.debug.swift`** (for testing):
```swift
enum Secrets {
static let revenueCatAPIKey = "test_your_test_key"
}
```
**`Secrets.release.swift`** (for production):
```swift
enum Secrets {
static let revenueCatAPIKey = "appl_your_production_key"
}
```
Use build configurations or compiler flags to include the correct file.
---
## PremiumManager Implementation
Create a centralized manager for all RevenueCat interactions.
### Complete Implementation
```swift
import RevenueCat
import SwiftUI
@MainActor
@Observable
final class PremiumManager {
// MARK: - Published State
/// Available packages for purchase
var availablePackages: [Package] = []
// MARK: - Configuration
/// Entitlement identifier - must match RevenueCat dashboard
private let entitlementIdentifier = "pro"
/// Task for listening to customer info updates
@ObservationIgnored private var customerInfoTask: Task<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 causes a common bug: **after purchasing from a paywall triggered outside of onboarding (e.g., from Settings), the UI doesn't reflect the new premium status** even though the purchase succeeded.
### Why It Happens
1. Your `SettingsViewModel` (or similar) has a `PremiumManager` instance
2. `isPremiumUnlocked` is computed from `premiumManager.isPremiumUnlocked`
3. `PremiumManager.isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo`
4. After purchase, the cached customer info IS updated
5. But SwiftUI doesn't know to re-render because no `@Observable` property changed
### Solution: Refresh Token Pattern
Add a refresh mechanism that forces SwiftUI to re-evaluate the premium status:
```swift
@MainActor
@Observable
final class SettingsViewModel {
@ObservationIgnored let premiumManager = PremiumManager()
// Refresh token that forces re-evaluation when incremented
private var premiumRefreshToken: Int = 0
/// Premium status - always reads current value from RevenueCat
var isPremiumUnlocked: Bool {
// Access refresh token to create observation dependency
_ = premiumRefreshToken
return premiumManager.isPremiumUnlocked
}
/// Force SwiftUI to re-evaluate premium status
/// Call this after paywall dismisses
func refreshPremiumStatus() {
premiumRefreshToken += 1
}
}
```
### Triggering the Refresh
Call `refreshPremiumStatus()` when the paywall sheet dismisses:
```swift
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var showPaywall = false
var body: some View {
// ... your content ...
.sheet(isPresented: $showPaywall, onDismiss: {
// Force UI to re-check premium status after paywall closes
settings.refreshPremiumStatus()
}) {
PaywallPresenter()
}
}
}
```
### Alternative: Shared PremiumManager
Instead of each view model having its own `PremiumManager`, you can use a single shared instance passed through the environment:
```swift
@main
struct YourApp: App {
@State private var premiumManager = PremiumManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(premiumManager)
.task {
await premiumManager.checkSubscriptionStatus()
premiumManager.startListeningForCustomerInfoUpdates()
}
}
}
}
// In views:
struct SettingsView: View {
@Environment(PremiumManager.self) private var premiumManager
var body: some View {
if premiumManager.isPremiumUnlocked {
// Premium content
}
}
}
```
This approach ensures all views observe the same instance and receive updates together.
### Key Takeaways
1. **Always refresh after paywall dismisses** - Use `onDismiss` on the sheet
2. **Use a refresh token** for computed properties that read from external sources
3. **Consider a shared instance** if premium status is checked in many places
4. **Test the full flow** - Skip onboarding, trigger paywall from Settings, complete purchase, verify UI updates
---
## App Store Connect Setup
### 1. Create Your App
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. **My Apps****+** → **New App**
3. Fill in bundle ID (must match Xcode exactly)
### 2. Create Subscription Group
1. In your app → **Subscriptions**
2. Click **+** next to "Subscription Groups"
3. Name it (e.g., "Pro Access")
### 3. Create Products
**Auto-Renewable Subscriptions** (monthly, yearly):
| Field | Example Value |
|-------|--------------|
| Reference Name | Pro Monthly |
| Product ID | `com.company.app.pro.monthly` |
| Duration | 1 Month |
| Price | $2.99 |
**Non-Consumable** (lifetime):
| Field | Example Value |
|-------|--------------|
| Reference Name | Pro Lifetime |
| Product ID | `com.company.app.pro.lifetime` |
| Price | $39.99 |
### 4. Agreements
1. **Agreements, Tax, and Banking** → Accept **Paid Applications**
2. Add bank account and tax information
---
## RevenueCat Dashboard Setup
### 1. Create Project and App
1. Create project at [RevenueCat](https://app.revenuecat.com)
2. Add iOS app with your bundle ID
### 2. Connect to App Store
1. Get App-Specific Shared Secret from App Store Connect
2. Add to RevenueCat: **Project Settings****Apps** → your app
### 3. Create Products
Add each product with IDs matching App Store Connect exactly.
### 4. Create Entitlement
1. **Entitlements****+ New**
2. Name: `pro` (or your identifier)
3. Attach all products that grant this entitlement
### 5. Create Offering
1. **Offerings** → Edit **default**
2. Add packages:
- `$rc_monthly` → monthly product
- `$rc_annual` → yearly product
- `$rc_lifetime` → lifetime product
### 6. Design Paywall (Optional)
1. In your offering, click **Add Paywall**
2. Use the visual editor to design
3. Configure colors, text, layout
### 7. Get API Keys
- **Test Key** (starts with `test_`): For development, free purchases
- **Production Key** (starts with `appl_`): For App Store builds
---
## Testing Strategies
### 1. Debug Premium Toggle (Fastest)
For UI testing without purchase flow:
```swift
#if DEBUG
Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled)
#endif
```
### 2. StoreKit Configuration File (Local)
1. **File****New** → **StoreKit Configuration File**
2. Add products matching your IDs
3. **Edit Scheme****Run****Options** → Set StoreKit Configuration
4. Purchases are instant and free
### 3. RevenueCat Test Mode (Integration)
Use the test API key (`test_`):
- Purchases are free
- Transactions appear in RevenueCat dashboard
- Restore not available (returns cached info)
### 4. Sandbox Testing (Full)
1. Create Sandbox Tester in App Store Connect
2. Sign out of App Store on device
3. Make purchase, sign in with sandbox credentials
4. Verify in RevenueCat dashboard
**Sandbox Renewal Times:**
| Real | Sandbox |
|------|---------|
| 1 week | 3 min |
| 1 month | 5 min |
| 1 year | 1 hour |
---
## Production Checklist
### Code
- [ ] PremiumManager configured with production API key
- [ ] Secrets file is gitignored
- [ ] Error handling for all purchase scenarios
- [ ] Loading states during purchases
- [ ] Restore purchases functionality
- [ ] Real-time subscription status updates
### App Store Connect
- [ ] App created with correct bundle ID
- [ ] All products created with localizations
- [ ] Subscription group configured
- [ ] Paid Apps agreement accepted
- [ ] Bank and tax info submitted
### RevenueCat Dashboard
- [ ] Production app created with bundle ID
- [ ] Shared secret configured
- [ ] All products added (IDs match exactly)
- [ ] Entitlement created with products attached
- [ ] Offering configured with packages
- [ ] (Optional) Paywall designed
### Testing
- [ ] Tested with StoreKit Configuration
- [ ] Tested with sandbox account
- [ ] Verified transactions in RevenueCat dashboard
- [ ] Tested restore purchases
- [ ] Tested subscription expiration/renewal
---
## Troubleshooting
### Products Not Loading
1. Verify product IDs match exactly (case-sensitive)
2. Check API key is correct
3. Ensure shared secret is configured
4. Wait a few minutes after creating products
### "Purchases has not been configured"
- `Purchases.configure(withAPIKey:)` must be called before any other SDK methods
- Ensure it's called early in app lifecycle (App init or `@main`)
### Sandbox Purchase Fails
1. Sign out of regular App Store
2. Use sandbox credentials (not real Apple ID)
3. Check Paid Apps agreement is accepted
4. Ensure products have prices set
### Entitlements Not Granting Access
1. Verify entitlement identifier matches code exactly
2. Check products are attached to entitlement
3. Confirm packages are in the offering
### RevenueCat Dashboard Empty
1. Using test key shows in sandbox environment
2. Check bundle ID matches exactly
3. Wait 1-2 minutes for transactions to appear
---
## Resources
- [RevenueCat Documentation](https://docs.revenuecat.com)
- [RevenueCat iOS SDK Reference](https://sdk.revenuecat.com/ios/index.html)
- [App Store Connect Help](https://help.apple.com/app-store-connect/)
- [StoreKit Documentation](https://developer.apple.com/documentation/storekit)