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

This commit is contained in:
Matt Bruce 2026-02-03 13:16:01 -06:00
parent 658423ee16
commit 3c7f8a39db
16 changed files with 753 additions and 48 deletions

36
.gitignore vendored Normal file
View File

@ -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

View File

@ -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
Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed for Views to stay consistent.

View File

@ -44,7 +44,7 @@ struct RootView: View {
}
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
PaywallPresenter()
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -113,7 +113,7 @@ struct ContentView: View {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
PaywallPresenter()
}
}

View File

@ -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)

View File

@ -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)"))
}
}

View File

@ -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,7 +447,42 @@ struct SettingsView: View {
// MARK: - Pro Section
@ViewBuilder
private var proSection: some View {
if isPremiumUnlocked {
// User has Pro - show status and manage link via Customer Center
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "checkmark.seal.fill")
.font(.title2)
.foregroundStyle(AppStatus.success)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Pro Active"))
.font(.system(size: Design.FontSize.medium, weight: .semibold))
.foregroundStyle(.white)
Button {
showCustomerCenter = true
} label: {
Text(String(localized: "Manage Subscription"))
.font(.system(size: Design.FontSize.caption))
.foregroundStyle(AppAccent.primary)
}
}
Spacer()
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.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
@ -483,6 +522,7 @@ struct SettingsView: View {
.accessibilityLabel(String(localized: "Upgrade to Pro"))
.accessibilityHint(String(localized: "Opens upgrade options"))
}
}
// MARK: - iCloud Sync Section

View File

@ -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)
}

View File

@ -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<Void, Never>?
/// 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
}
}

View File

@ -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)