From 3c7f8a39db7317eeb41466398af9f3f39185c6ae Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 3 Feb 2026 13:16:01 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .gitignore | 36 ++ AGENTS.md | 2 +- .../UserInterfaceState.xcuserstate | Bin 19405 -> 21690 bytes SelfieCam/App/RootView.swift | 2 +- SelfieCam/App/SelfieCamApp.swift | 10 + SelfieCam/Configuration/Debug.xcconfig | 2 +- SelfieCam/Configuration/Release.xcconfig | 2 +- SelfieCam/Configuration/Secrets.xcconfig | 10 - .../Configuration/Secrets.xcconfig.template | 22 +- .../Features/Camera/Views/ContentView.swift | 2 +- .../Views/OnboardingSoftPaywallView.swift | 31 ++ .../Paywall/Views/ProPaywallView.swift | 44 ++- .../Settings/Views/SettingsView.swift | 84 ++-- .../Shared/Premium/PaywallPresenter.swift | 126 ++++++ SelfieCam/Shared/Premium/PremiumManager.swift | 56 ++- docs/IN_APP_PURCHASE_SETUP.md | 372 ++++++++++++++++++ 16 files changed, 753 insertions(+), 48 deletions(-) create mode 100644 .gitignore delete mode 100644 SelfieCam/Configuration/Secrets.xcconfig create mode 100644 SelfieCam/Shared/Premium/PaywallPresenter.swift create mode 100644 docs/IN_APP_PURCHASE_SETUP.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79e0e61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# SelfieCam .gitignore + +# Xcode +*.xcuserstate +*.xccheckout +*.xcscmblueprint +xcuserdata/ +DerivedData/ + +# Build +build/ +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +Pods/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# Secrets - API keys and sensitive configuration +# ⚠️ NEVER commit these files +Secrets.xcconfig +Secrets.debug.xcconfig +Secrets.release.xcconfig + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index ba65d94..eba130f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,4 +9,4 @@ Always try to build after coding to ensure no build errors exist and use the iPh Try and use xcode build mcp if it is working and test using screenshots when asked. -Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed. Read the REA \ No newline at end of file +Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed for Views to stay consistent. \ No newline at end of file diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index 79392662f36b93fe42c8711cae2d194c3237ea84..65cd1d7cec4041f9436ec1b9a7e8b07e7f82ab66 100644 GIT binary patch delta 12454 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 diff --git a/SelfieCam/App/RootView.swift b/SelfieCam/App/RootView.swift index 23e05a8..3d9af52 100644 --- a/SelfieCam/App/RootView.swift +++ b/SelfieCam/App/RootView.swift @@ -44,7 +44,7 @@ struct RootView: View { } } .sheet(isPresented: $showPaywall) { - ProPaywallView() + PaywallPresenter() } } } diff --git a/SelfieCam/App/SelfieCamApp.swift b/SelfieCam/App/SelfieCamApp.swift index ecfbc20..c443eac 100644 --- a/SelfieCam/App/SelfieCamApp.swift +++ b/SelfieCam/App/SelfieCamApp.swift @@ -10,6 +10,9 @@ import Bedrock @main struct SelfieCamApp: App { + /// Premium manager for checking subscription status on launch + @State private var premiumManager = PremiumManager() + init() { Design.showDebugLogs = true @@ -37,6 +40,13 @@ struct SelfieCamApp: App { // Set screen brightness to 100% on app launch UIScreen.main.brightness = 1.0 } + .task { + // Refresh subscription status on launch (handles fresh installs) + await premiumManager.checkSubscriptionStatus() + + // Start listening for real-time customer info updates + premiumManager.startListeningForCustomerInfoUpdates() + } } } } diff --git a/SelfieCam/Configuration/Debug.xcconfig b/SelfieCam/Configuration/Debug.xcconfig index 0689af4..3ad9177 100644 --- a/SelfieCam/Configuration/Debug.xcconfig +++ b/SelfieCam/Configuration/Debug.xcconfig @@ -2,7 +2,7 @@ // Configuration for Debug builds #include "Base.xcconfig" -#include? "Secrets.xcconfig" +#include? "Secrets.debug.xcconfig" // If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values // CI/CD should set these via environment variables diff --git a/SelfieCam/Configuration/Release.xcconfig b/SelfieCam/Configuration/Release.xcconfig index d68603d..be2a1ef 100644 --- a/SelfieCam/Configuration/Release.xcconfig +++ b/SelfieCam/Configuration/Release.xcconfig @@ -2,7 +2,7 @@ // Configuration for Release builds #include "Base.xcconfig" -#include? "Secrets.xcconfig" +#include? "Secrets.release.xcconfig" // If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values // CI/CD should set these via environment variables diff --git a/SelfieCam/Configuration/Secrets.xcconfig b/SelfieCam/Configuration/Secrets.xcconfig deleted file mode 100644 index e2f2929..0000000 --- a/SelfieCam/Configuration/Secrets.xcconfig +++ /dev/null @@ -1,10 +0,0 @@ -// Secrets.xcconfig -// -// ⚠️ DO NOT COMMIT THIS FILE TO VERSION CONTROL -// This file contains sensitive API keys and secrets. -// -// For CI/CD: Set these values via environment variables in your build system. - -// RevenueCat API Key -// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key -REVENUECAT_API_KEY = your_revenuecat_public_api_key_here diff --git a/SelfieCam/Configuration/Secrets.xcconfig.template b/SelfieCam/Configuration/Secrets.xcconfig.template index 2712067..1b7b2f4 100644 --- a/SelfieCam/Configuration/Secrets.xcconfig.template +++ b/SelfieCam/Configuration/Secrets.xcconfig.template @@ -1,12 +1,22 @@ // Secrets.xcconfig.template // // INSTRUCTIONS: -// 1. Copy this file to "Secrets.xcconfig" in the same directory -// 2. Replace the placeholder values with your actual API keys -// 3. NEVER commit Secrets.xcconfig to version control +// This project uses separate secrets files for Debug and Release builds: // -// The actual Secrets.xcconfig file is gitignored for security. +// 1. Copy this file to "Secrets.debug.xcconfig" for development/testing +// - Use your RevenueCat TEST API key (starts with test_) +// - Sandbox purchases, no real money charged +// +// 2. Copy this file to "Secrets.release.xcconfig" for App Store builds +// - Use your RevenueCat PRODUCTION API key (starts with appl_) +// - Real purchases, charges real money +// +// 3. NEVER commit the actual secrets files to version control +// +// The actual Secrets.*.xcconfig files are gitignored for security. // RevenueCat API Key -// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key -REVENUECAT_API_KEY = your_revenuecat_public_api_key_here +// Get this from: RevenueCat Dashboard > Project Settings > API Keys +// - Test key: Use "Public App-Specific API Key" from test environment +// - Production key: Use "Public App-Specific API Key" from production environment +REVENUECAT_API_KEY = your_revenuecat_api_key_here diff --git a/SelfieCam/Features/Camera/Views/ContentView.swift b/SelfieCam/Features/Camera/Views/ContentView.swift index 7d233e0..7e753ed 100644 --- a/SelfieCam/Features/Camera/Views/ContentView.swift +++ b/SelfieCam/Features/Camera/Views/ContentView.swift @@ -113,7 +113,7 @@ struct ContentView: View { SettingsView(viewModel: settings, showPaywall: $showPaywall) } .sheet(isPresented: $showPaywall) { - ProPaywallView() + PaywallPresenter() } } diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift index dca3bcc..a393745 100644 --- a/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift +++ b/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift @@ -16,6 +16,12 @@ struct OnboardingSoftPaywallView: View { @Binding var showPaywall: Bool let onComplete: () -> Void + /// Premium manager for restore purchases + @State private var premiumManager = PremiumManager() + + /// Whether a restore is in progress + @State private var isRestoring = false + var body: some View { OnboardingContentContainer { VStack(spacing: Design.Spacing.large) { @@ -87,6 +93,31 @@ struct OnboardingSoftPaywallView: View { onComplete() } ) + + // Restore Purchases button + Button { + Task { + isRestoring = true + try? await premiumManager.restorePurchases() + isRestoring = false + + // Check if premium was restored + if premiumManager.isPremiumUnlocked { + viewModel.completeOnboarding(settingsViewModel: settingsViewModel) + onComplete() + } + } + } label: { + if isRestoring { + ProgressView() + .tint(.secondary) + } else { + Text(String(localized: "Restore Purchases")) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .disabled(isRestoring) } .padding(.horizontal, Design.Spacing.xLarge) .padding(.bottom, Design.Spacing.xLarge) diff --git a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift index e1a69dc..75d7a60 100644 --- a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift @@ -83,9 +83,38 @@ private struct ProductPackageButton: View { let isPremiumUnlocked: Bool let onPurchase: () -> Void + private var isLifetime: Bool { + package.packageType == .lifetime + } + + private var isAnnual: Bool { + package.packageType == .annual + } + + /// Background color based on package type + private var backgroundColor: Color { + isLifetime ? AppStatus.warning.opacity(Design.Opacity.medium) : AppAccent.primary.opacity(Design.Opacity.medium) + } + + /// Border color based on package type + private var borderColor: Color { + isLifetime ? AppStatus.warning : AppAccent.primary + } + var body: some View { Button(action: onPurchase) { VStack(spacing: Design.Spacing.small) { + // Badge for lifetime + if isLifetime { + Text(String(localized: "ONE TIME")) + .font(.caption2.bold()) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xxSmall) + .background(AppStatus.warning) + .clipShape(Capsule()) + } + Text(package.storeProduct.localizedTitle) .font(.headline) .foregroundStyle(.white) @@ -94,7 +123,12 @@ private struct ProductPackageButton: View { .font(.title2.bold()) .foregroundStyle(.white) - if package.packageType == .annual { + // Subtitle based on type + if isLifetime { + Text(String(localized: "Pay once, own forever")) + .font(.caption) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) + } else if isAnnual { Text(String(localized: "Best Value • Save 33%")) .font(.caption) .foregroundStyle(.white.opacity(Design.Opacity.accent)) @@ -102,14 +136,16 @@ private struct ProductPackageButton: View { } .frame(maxWidth: .infinity) .padding(Design.Spacing.large) - .background(AppAccent.primary.opacity(Design.Opacity.medium)) + .background(backgroundColor) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(AppAccent.primary, lineWidth: Design.LineWidth.thin) + .strokeBorder(borderColor, lineWidth: Design.LineWidth.thin) ) } - .accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) + .accessibilityLabel(isLifetime + ? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") + : String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) } } diff --git a/SelfieCam/Features/Settings/Views/SettingsView.swift b/SelfieCam/Features/Settings/Views/SettingsView.swift index 9152552..aad7580 100644 --- a/SelfieCam/Features/Settings/Views/SettingsView.swift +++ b/SelfieCam/Features/Settings/Views/SettingsView.swift @@ -1,12 +1,16 @@ import SwiftUI import Bedrock import MijickCamera +import RevenueCatUI struct SettingsView: View { @Bindable var viewModel: SettingsViewModel @Binding var showPaywall: Bool @Environment(\.dismiss) private var dismiss + /// Whether to show RevenueCat Customer Center + @State private var showCustomerCenter = false + /// Whether premium features are unlocked (for UI gating) private var isPremiumUnlocked: Bool { viewModel.isPremiumUnlocked @@ -443,45 +447,81 @@ struct SettingsView: View { // MARK: - Pro Section + @ViewBuilder private var proSection: some View { - Button { - dismiss() - // Small delay to allow sheet to dismiss before showing paywall - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - showPaywall = true - } - } label: { + if isPremiumUnlocked { + // User has Pro - show status and manage link via Customer Center HStack(spacing: Design.Spacing.medium) { - Image(systemName: "crown.fill") + Image(systemName: "checkmark.seal.fill") .font(.title2) - .foregroundStyle(AppStatus.warning) + .foregroundStyle(AppStatus.success) VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(String(localized: "Upgrade to Pro")) + Text(String(localized: "Pro Active")) .font(.system(size: Design.FontSize.medium, weight: .semibold)) .foregroundStyle(.white) - Text(String(localized: "Premium colors, HDR, timers & more")) - .font(.system(size: Design.FontSize.caption)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) + Button { + showCustomerCenter = true + } label: { + Text(String(localized: "Manage Subscription")) + .font(.system(size: Design.FontSize.caption)) + .foregroundStyle(AppAccent.primary) + } } Spacer() - - Image(systemName: "chevron.right") - .font(.body) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) } .padding(Design.Spacing.medium) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(AppAccent.primary.opacity(Design.Opacity.subtle)) - .strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + .fill(AppStatus.success.opacity(Design.Opacity.subtle)) + .strokeBorder(AppStatus.success.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) ) + .accessibilityLabel(String(localized: "Pro subscription active")) + .accessibilityHint(String(localized: "Tap Manage Subscription to view or cancel")) + .presentCustomerCenter(isPresented: $showCustomerCenter) + } else { + // User doesn't have Pro - show upgrade button + Button { + dismiss() + // Small delay to allow sheet to dismiss before showing paywall + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showPaywall = true + } + } label: { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: "crown.fill") + .font(.title2) + .foregroundStyle(AppStatus.warning) + + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(String(localized: "Upgrade to Pro")) + .font(.system(size: Design.FontSize.medium, weight: .semibold)) + .foregroundStyle(.white) + + Text(String(localized: "Premium colors, HDR, timers & more")) + .font(.system(size: Design.FontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.body) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(AppAccent.primary.opacity(Design.Opacity.subtle)) + .strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Upgrade to Pro")) + .accessibilityHint(String(localized: "Opens upgrade options")) } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Upgrade to Pro")) - .accessibilityHint(String(localized: "Opens upgrade options")) } // MARK: - iCloud Sync Section diff --git a/SelfieCam/Shared/Premium/PaywallPresenter.swift b/SelfieCam/Shared/Premium/PaywallPresenter.swift new file mode 100644 index 0000000..3e7a554 --- /dev/null +++ b/SelfieCam/Shared/Premium/PaywallPresenter.swift @@ -0,0 +1,126 @@ +// +// PaywallPresenter.swift +// SelfieCam +// +// Presents RevenueCat native Paywall with automatic fallback to custom PaywallView +// if the RevenueCat paywall fails to load or is not configured. +// + +import SwiftUI +import RevenueCat +import RevenueCatUI +import Bedrock + +/// Presents RevenueCat Paywall with fallback to custom paywall. +/// +/// Usage: +/// ```swift +/// .sheet(isPresented: $showPaywall) { +/// PaywallPresenter() +/// } +/// ``` +/// +/// The presenter will: +/// 1. Attempt to load the RevenueCat paywall from your configured offering +/// 2. If successful, display the native RevenueCat PaywallView +/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView +struct PaywallPresenter: View { + @Environment(\.dismiss) private var dismiss + @State private var offering: Offering? + @State private var isLoading = true + @State private var useFallback = false + + var body: some View { + Group { + if isLoading { + // Loading state while fetching offerings + loadingView + } else if useFallback { + // Fallback to custom paywall on error + ProPaywallView() + } else if let offering { + // RevenueCat native paywall + paywallView(for: offering) + } else { + // No offering available, use fallback + ProPaywallView() + } + } + .task { + await loadOffering() + } + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack { + ProgressView() + .scaleEffect(1.5) + .tint(.white) + + Text(String(localized: "Loading...")) + .font(.system(size: Design.FontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .padding(.top, Design.Spacing.medium) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppSurface.primary) + } + + // MARK: - Paywall View + + private func paywallView(for offering: Offering) -> some View { + PaywallView(offering: offering) + .onPurchaseCompleted { customerInfo in + #if DEBUG + print("✅ [PaywallPresenter] Purchase completed") + #endif + dismiss() + } + .onRestoreCompleted { customerInfo in + #if DEBUG + print("✅ [PaywallPresenter] Restore completed") + #endif + dismiss() + } + } + + // MARK: - Load Offering + + private func loadOffering() async { + do { + let offerings = try await Purchases.shared.offerings() + + // Check if current offering has a paywall configured + if let current = offerings.current { + offering = current + isLoading = false + + #if DEBUG + print("✅ [PaywallPresenter] Loaded offering: \(current.identifier)") + #endif + } else { + // No current offering, use fallback + #if DEBUG + print("⚠️ [PaywallPresenter] No current offering available, using fallback") + #endif + useFallback = true + isLoading = false + } + } catch { + #if DEBUG + print("⚠️ [PaywallPresenter] Failed to load offerings: \(error)") + #endif + useFallback = true + isLoading = false + } + } +} + +// MARK: - Preview + +#Preview { + PaywallPresenter() + .preferredColorScheme(.dark) +} diff --git a/SelfieCam/Shared/Premium/PremiumManager.swift b/SelfieCam/Shared/Premium/PremiumManager.swift index ed1ab82..65ac5c8 100644 --- a/SelfieCam/Shared/Premium/PremiumManager.swift +++ b/SelfieCam/Shared/Premium/PremiumManager.swift @@ -9,7 +9,10 @@ final class PremiumManager: PremiumManaging { // MARK: - Configuration /// RevenueCat entitlement identifier - must match your RevenueCat dashboard - private let entitlementIdentifier = "pro" + private let entitlementIdentifier = "Selfie Cam by TopDog Pro" + + /// Task for listening to customer info updates + @ObservationIgnored private var customerInfoTask: Task? /// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig) private static var apiKey: String { @@ -153,4 +156,55 @@ final class PremiumManager: PremiumManaging { argument: String(localized: "Purchases restored") ) } + + // MARK: - Subscription Status Check + + /// Explicitly fetches fresh customer info from RevenueCat. + /// Call this on app launch to handle fresh installs where the user + /// may have an active subscription from another device. + func checkSubscriptionStatus() async { + guard !Self.apiKey.isEmpty else { return } + + do { + // Fetch fresh customer info - this updates the cached info used by isPremium + _ = try await Purchases.shared.customerInfo() + } catch { + #if DEBUG + print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)") + #endif + } + } + + // MARK: - Customer Info Listener + + /// Starts listening for real-time customer info updates from RevenueCat. + /// This enables reactive UI updates when subscription status changes + /// (e.g., user subscribes, cancels, or subscription expires). + func startListeningForCustomerInfoUpdates() { + guard !Self.apiKey.isEmpty else { return } + + // Cancel any existing listener + 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 + + // The @Observable framework will automatically notify observers + // when isPremium is accessed after this update since it reads + // from Purchases.shared.cachedCustomerInfo which is now updated + } + } + } + + /// Stops listening for customer info updates. + /// Call this when the manager is no longer needed. + func stopListeningForCustomerInfoUpdates() { + customerInfoTask?.cancel() + customerInfoTask = nil + } } diff --git a/docs/IN_APP_PURCHASE_SETUP.md b/docs/IN_APP_PURCHASE_SETUP.md new file mode 100644 index 0000000..ef66159 --- /dev/null +++ b/docs/IN_APP_PURCHASE_SETUP.md @@ -0,0 +1,372 @@ +# In-App Purchase Setup Guide + +This guide walks through setting up Monthly, Yearly, and Lifetime in-app purchases for SelfieCam using RevenueCat. + +## Prerequisites + +- Apple Developer Program membership ($99/year) +- RevenueCat account (free tier available) +- Xcode with SelfieCam project + +--- + +## Part 1: App Store Connect Setup + +### Step 1: Create Your App + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. Click **My Apps** → **+** → **New App** +3. Fill in: + - **Platform**: iOS + - **Name**: SelfieCam (or your app name) + - **Primary Language**: English (US) + - **Bundle ID**: Select your app's bundle ID (must match Xcode) + - **SKU**: A unique identifier (e.g., `selfiecam2026`) +4. Click **Create** + +### Step 2: Create a Subscription Group + +Subscriptions must belong to a group. Users can only have one active subscription per group. + +1. In your app, go to **Subscriptions** (left sidebar under "In-App Purchases") +2. Click **+** next to "Subscription Groups" +3. Enter group name: `SelfieCam Pro` +4. Click **Create** + +### Step 3: Create Monthly Subscription + +1. In your subscription group, click **+** next to "Subscriptions" +2. Fill in: + - **Reference Name**: Pro Monthly (internal only) + - **Product ID**: `com.mbrucedogs.SelfieCam.pro.monthly` + - Replace `yourcompany` with your actual company/developer name + - This ID is permanent and cannot be changed +3. Click **Create** +4. On the subscription page: + - **Subscription Duration**: 1 Month + - **Subscription Prices**: Click **+**, select your base country, enter price (e.g., $2.99/month) + - **App Store Localization**: Click **+**, add English (US): + - **Display Name**: Pro Monthly + - **Description**: Unlock all premium features with monthly access +5. Click **Save** + +### Step 4: Create Yearly Subscription + +1. In your subscription group, click **+** next to "Subscriptions" +2. Fill in: + - **Reference Name**: Pro Yearly + - **Product ID**: `com.mbrucedogs.SelfieCam.pro.yearly` +3. Click **Create** +4. On the subscription page: + - **Subscription Duration**: 1 Year + - **Subscription Prices**: Enter price (e.g., $19.99/year - ~33% savings vs monthly) + - **App Store Localization**: + - **Display Name**: Pro Yearly + - **Description**: Best value! Unlock all premium features for a full year +5. Click **Save** + +### Step 5: Create Lifetime (Non-Consumable) + +Lifetime purchases are NOT subscriptions - they're non-consumable in-app purchases. + +1. Go to **In-App Purchases** (left sidebar, separate from Subscriptions) +2. Click **+** → **Non-Consumable** +3. Fill in: + - **Reference Name**: Pro Lifetime + - **Product ID**: `com.brumcedogs.SelfieCam.pro.lifetime` +4. Click **Create** +5. On the product page: + - **Price Schedule**: Click **+**, select base country, enter price (e.g., $39.99) + - **App Store Localization**: Click **+**, add English (US): + - **Display Name**: Pro Lifetime + - **Description**: Pay once, own forever. All premium features unlocked permanently. +6. Click **Save** + +### Step 6: Agreements and Tax Setup + +Before you can sell, you must accept agreements: + +1. Go to [Agreements, Tax, and Banking](https://appstoreconnect.apple.com/agreements) +2. Accept the **Paid Applications** agreement +3. Fill in your **Bank Account** information +4. Fill in your **Tax Forms** (varies by country) + +> **Note**: Products will show "Missing Metadata" until your app is submitted. This is normal. + +--- + +## Part 2: RevenueCat Setup + +### Step 1: Create RevenueCat Account + +1. Go to [RevenueCat](https://www.revenuecat.com) +2. Click **Get Started** → **Sign up** +3. Choose the free tier (covers up to $2,500/month in revenue) + +### Step 2: Create a Project + +1. After signup, click **Create New Project** +2. Enter project name: `SelfieCam` +3. Click **Create Project** + +### Step 3: Add Your iOS App + +1. In your project, go to **Project Settings** (gear icon) → **Apps** +2. Click **+ New App** +3. Select **App Store** (iOS) +4. Fill in: + - **App Name**: SelfieCam + - **Bundle ID**: Your exact bundle ID from Xcode (e.g., `com.mbrucedogs.SelfieCam`) +5. Click **Save Changes** + +### Step 4: Connect to App Store Connect + +RevenueCat needs your App Store Connect shared secret to validate receipts: + +1. In App Store Connect, go to your app → **App Information** (left sidebar) +2. Scroll to **App-Specific Shared Secret** → Click **Manage** +3. Click **Generate** if you don't have one +4. Copy the shared secret +5. Back in RevenueCat, go to **Project Settings** → **Apps** → your iOS app +6. Paste the shared secret in **App Store Connect App-Specific Shared Secret** +7. Click **Save Changes** + +### Step 5: Create Products in RevenueCat + +1. Go to **Products** (left sidebar) +2. Click **+ New Product** for each: + +**Product 1 - Monthly:** +- **Identifier**: `com.mbrucedogs.SelfieCam.pro.monthly` (must match App Store Connect exactly) +- **App**: SelfieCam (iOS) +- Click **Add** + +**Product 2 - Yearly:** +- **Identifier**: `com.mbrucedogs.SelfieCam.pro.yearly` +- **App**: SelfieCam (iOS) +- Click **Add** + +**Product 3 - Lifetime:** +- **Identifier**: `com.mbrucedogs.SelfieCam.pro.lifetime` +- **App**: SelfieCam (iOS) +- Click **Add** + +### Step 6: Create an Entitlement + +Entitlements represent what the user "gets" - your code checks for this. + +1. Go to **Entitlements** (left sidebar) +2. Click **+ New Entitlement** +3. Enter identifier: `Selfie Cam by TopDog Pro` (this matches the code in `PremiumManager.swift`) +4. Click **Add** +5. Now attach all 3 products: + - Click on the `Selfie Cam by TopDog Pro` entitlement + - Click **Attach Products** + - Select all 3 products (monthly, yearly, lifetime) + - Click **Attach** + +### Step 7: Create an Offering + +Offerings group products for display in your paywall. + +1. Go to **Offerings** (left sidebar) +2. You'll see a default offering already exists +3. Click on **default** offering +4. Click **+ New Package** for each: + +**Package 1:** +- **Identifier**: Select `$rc_monthly` from dropdown +- **Product**: Select your monthly product +- Click **Add** + +**Package 2:** +- **Identifier**: Select `$rc_annual` from dropdown +- **Product**: Select your yearly product +- Click **Add** + +**Package 3:** +- **Identifier**: Select `$rc_lifetime` from dropdown +- **Product**: Select your lifetime product +- Click **Add** + +### Step 8: Get Your API Key + +1. Go to **Project Settings** (gear icon) → **API Keys** +2. Copy your **Public App-Specific API Key** (starts with `appl_`) +3. In your Xcode project, add this to `Configuration/Secrets.xcconfig`: + +``` +REVENUECAT_API_KEY = appl_your_api_key_here +``` + +> **Important**: Never commit `Secrets.xcconfig` to git. It should be in your `.gitignore`. + +--- + +## Part 3: Testing Purchases + +There are three ways to test purchases without paying real money. + +### Option A: Debug Premium Toggle (Fastest - No Setup Required) + +Use the built-in debug toggle to bypass premium checks entirely: + +1. Run the app in DEBUG mode +2. Go to **Settings** → scroll to **Debug** section +3. Toggle **Enable Debug Premium** on +4. All premium features are now unlocked + +This is useful for testing the UI but doesn't test the actual purchase flow. + +### Option B: StoreKit Configuration File (Local Testing) + +Test purchases locally without App Store Connect: + +1. **Create a StoreKit Configuration File** + - In Xcode: File → New → File → **StoreKit Configuration File** + - Name it `Products.storekit` + - Save it in the SelfieCam project folder + +2. **Add Your Products** + Click **+** in the editor and add: + + | Type | Reference Name | Product ID | + |------|---------------|------------| + | Auto-Renewable Subscription | Pro Monthly | `com.mbrucedogs.SelfieCam.pro.monthly` | + | Auto-Renewable Subscription | Pro Yearly | `com.mbrucedogs.SelfieCam.pro.yearly` | + | Non-Consumable | Pro Lifetime | `com.mbrucedogs.SelfieCam.pro.lifetime` | + + For subscriptions, create a subscription group called "SelfieCam Pro" first. + +3. **Enable It in Your Scheme** + - Product → Scheme → Edit Scheme (or ⌘<) + - Select **Run** → **Options** tab + - Set **StoreKit Configuration** to your `Products.storekit` file + +4. **Test Purchases** + - Run the app in Simulator or on device + - Purchases are instant and free + - Manage transactions: Debug → StoreKit → Manage Transactions + +> **Note**: StoreKit Configuration testing works with RevenueCat but transactions won't appear in the RevenueCat dashboard. + +### Option C: Sandbox Testing (Full Integration Test) + +Test the complete flow with App Store Connect and RevenueCat: + +1. **Create a Sandbox Tester Account** + - Go to [App Store Connect](https://appstoreconnect.apple.com) → **Users and Access** → **Sandbox** → **Testers** + - Click **+** to add a new tester + - Use a fake email you control (not a real Apple ID) + - Set a password you'll remember + +2. **Sign Out of App Store on Your Test Device** + - Settings → App Store → Tap your Apple ID → Sign Out + - **Don't sign back in yet** + +3. **Run Your App and Make a Purchase** + - Run the app on a physical device (recommended) or simulator + - Tap a purchase button + - When prompted to sign in, use your sandbox tester credentials + - Complete the purchase - it's free! + +4. **Verify in RevenueCat** + - Go to RevenueCat dashboard → **Customers** + - Search for your sandbox user + - You should see their entitlement and transaction + +**Sandbox Subscription Renewal Times:** + +| Real Duration | Sandbox Duration | +|--------------|------------------| +| 1 week | 3 minutes | +| 1 month | 5 minutes | +| 2 months | 10 minutes | +| 3 months | 15 minutes | +| 6 months | 30 minutes | +| 1 year | 1 hour | + +Subscriptions auto-renew up to 6 times in sandbox, then expire. + +--- + +## Setup Checklist + +### App Store Connect +- [ ] App created with correct bundle ID +- [ ] Subscription group created (`SelfieCam Pro`) +- [ ] Monthly subscription created with price and localization +- [ ] Yearly subscription created with price and localization +- [ ] Lifetime non-consumable created with price and localization +- [ ] Paid Apps agreement accepted +- [ ] Bank and tax info submitted +- [ ] Sandbox tester account created + +### RevenueCat +- [ ] Account created +- [ ] Project created +- [ ] iOS app added with bundle ID +- [ ] Shared secret from App Store Connect added +- [ ] 3 products created (matching App Store Connect IDs exactly) +- [ ] `pro` entitlement created +- [ ] All 3 products attached to `pro` entitlement +- [ ] Default offering has 3 packages (monthly, annual, lifetime) +- [ ] API key copied to `Secrets.xcconfig` + +### Xcode +- [ ] `Secrets.xcconfig` contains `REVENUECAT_API_KEY` +- [ ] `Secrets.xcconfig` is in `.gitignore` +- [ ] (Optional) StoreKit Configuration file created for local testing + +--- + +## Troubleshooting + +### Products Not Loading + +- Verify product IDs match exactly between App Store Connect and RevenueCat +- Check that the RevenueCat API key is correctly set in `Secrets.xcconfig` +- Ensure the shared secret is added in RevenueCat +- Products may take a few minutes to propagate after creation + +### Sandbox Purchases Failing + +- Make sure you're signed out of the regular App Store +- Use sandbox credentials, not your real Apple ID +- Check that agreements are accepted in App Store Connect +- Verify the device isn't in a restricted region + +### RevenueCat Not Showing Transactions + +- Sandbox transactions can take a minute to appear +- Verify the bundle ID matches exactly +- Check that products are attached to the entitlement +- Look at RevenueCat's debug logs in Xcode console + +### "Missing Metadata" in App Store Connect + +This is normal until you submit your app for review. Products will work in sandbox despite this warning. + +--- + +## Code Reference + +The following files handle in-app purchases: + +| File | Purpose | +|------|---------| +| `Shared/Premium/PremiumManager.swift` | RevenueCat integration, purchase logic, customer info listener | +| `Shared/Premium/PaywallPresenter.swift` | RevenueCat native Paywall with custom fallback | +| `Features/Paywall/Views/ProPaywallView.swift` | Custom fallback paywall UI | +| `Features/Onboarding/Views/OnboardingSoftPaywallView.swift` | Onboarding soft paywall | +| `Features/Settings/Views/SettingsView.swift` | Pro section with Customer Center | +| `Configuration/Secrets.xcconfig` | RevenueCat API key (not committed to git) | + +The code checks for a single entitlement called `"Selfie Cam by TopDog Pro"`. All three products (monthly, yearly, lifetime) grant this same entitlement, so the app doesn't need to know which one the user purchased. + +### RevenueCat Features Used + +- **RevenueCat SDK** - Core purchase and subscription management +- **RevenueCatUI** - Native paywalls and Customer Center +- **PaywallView** - Remote-configurable paywall designed in RevenueCat dashboard +- **Customer Center** - Subscription management UI (view plan, cancel, request refund)