> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swiftaiboilerplate.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payments Module

> Production **subscription management** via RevenueCat with **reactive state**, **entitlement checking**, and **beautiful paywall UI**.

<Info>
  **Full technical details** in your project at `/docs/modules/Payments.md`
</Info>

## What You Get

* ✅ **RevenueCat wrapper** - Clean abstraction, no RC types leak
* ✅ **Reactive state** - AsyncStream for subscription changes
* ✅ **Purchase flows** - Buy, restore, cancel with full error handling
* ✅ **Entitlement system** - Single "pro" entitlement (expandable)
* ✅ **Paywall UI** - Beautiful, themeable subscription screen
* ✅ **Thread-safe** - Multi-subscriber support with state replay

**Time saved: 16-24 hours** of RevenueCat integration, state management, paywall UI, and testing.

## Key Components

### PaymentsClient Protocol

```swift theme={null}
public protocol PaymentsClient: Sendable {
    /// Configure the payments system. Call once at app start. Idempotent.
    func configure(_ config: PaymentsConfig) async

    /// Stream of subscription state changes
    func states() -> AsyncStream<PaymentsState>

    /// Get current subscription state immediately (cached)
    func currentState() async -> PaymentsState

    /// Purchase a product by ID
    func purchase(productID: String) async throws

    /// Restore previous purchases, returning the resulting state
    @discardableResult
    func restore() async throws -> PaymentsState

    /// Prefetch offerings for the paywall (optional optimization)
    func prefetchOfferings() async

    /// Get available product offerings with pricing
    func getOfferings() async throws -> [PaymentsOffering]
}
```

### PaymentsState

```swift theme={null}
public struct PaymentsState: Sendable, Equatable {
    public let isSubscribed: Bool
    public let activeEntitlementIDs: Set<String>
    public let expirationDate: Date?
    public let productID: String?
}
```

## Production Setup (From Real Code)

The app entry point builds a `PaymentsConfig` from the generated `AppConfiguration`
(produced at build time from `Config/Secrets.xcconfig`), then hands it to
`CompositionRoot`:

```swift theme={null}
// SwiftAIBoilerplatePro.swift — loadPaymentsConfig()
private func loadPaymentsConfig() throws -> PaymentsConfig {
    #if DEBUG
    // In DEBUG with AUTH_BYPASS=1, return a placeholder config (the mock client gets sample offerings)
    if ProcessInfo.processInfo.environment["AUTH_BYPASS"] == "1" {
        return PaymentsConfig(apiKey: "debug_mode_placeholder_key", entitlementID: "pro")
    }
    #endif

    guard !AppConfiguration.REVENUECAT_API_KEY.isEmpty else {
        throw AppError.validation(message: "Missing REVENUECAT_API_KEY. Add to Config/Secrets.xcconfig")
    }

    return PaymentsConfig(
        apiKey: AppConfiguration.REVENUECAT_API_KEY,
        entitlementID: AppConfiguration.RC_ENTITLEMENT_ID
    )
}
```

`CompositionRoot` constructs the client with that config. In DEBUG with
`AUTH_BYPASS` set, it swaps in `MockPaymentsClient` so the run-with-mocks
experience (and UI tests) works fully offline with sample offerings:

```swift theme={null}
// CompositionRoot.swift
#if DEBUG
if ProcessInfo.processInfo.environment["AUTH_BYPASS"] != "0" {
    self.paymentsClient = PreviewMocks.MockPaymentsClient()
} else {
    self.paymentsClient = Payments.RevenueCatClient(config: paymentsConfig)
}
#else
self.paymentsClient = Payments.RevenueCatClient(config: paymentsConfig)
#endif

// RevenueCatClient automatically:
// - Syncs with App Store
// - Tracks subscription status via the customer info stream
// - Maps RevenueCat receipts to PaymentsState
// - Emits state changes via states() (AsyncStream)
```

### Configuration Files

**Config/Secrets.xcconfig** (copy from `Config/Secrets.example.xcconfig`):

```bash theme={null}
# From RevenueCat Dashboard → Project Settings → API Keys
REVENUECAT_API_KEY = YOUR_RC_KEY

# Entitlement ID from RevenueCat Dashboard
RC_ENTITLEMENT_ID = pro
```

`scripts/update-config.sh` reads these values and regenerates
`SwiftAIBoilerplatePro/Generated/Configuration.swift` (`AppConfiguration`).

**Complete setup:** [RevenueCat Setup Guide](/pages/guides/revenuecat-setup)

## Subscription Flow

### Purchase

```swift theme={null}
// PaywallViewModel.purchase() buys the selected offering
public func purchase() async {
    guard let offering = selectedOffering else {
        errorMessage = "Please select a subscription plan"
        return
    }
    do {
        try await paymentsClient.purchase(productID: offering.id)
        // Success! State updates automatically via states()
    } catch let paymentsError as PaymentsError {
        errorMessage = paymentsError.asAppError().localizedUserMessage
    } catch {
        errorMessage = AppError.from(error).localizedUserMessage
    }
}
```

### Restore

```swift theme={null}
// PaywallViewModel.restore() returns the resulting state directly
public func restore() async {
    do {
        let restoredState = try await paymentsClient.restore()
        isSubscribed = restoredState.isSubscribed
        if !restoredState.isSubscribed {
            errorMessage = "No active subscription found to restore."
        }
    } catch let paymentsError as PaymentsError {
        errorMessage = paymentsError.asAppError().localizedUserMessage
    } catch {
        errorMessage = AppError.from(error).localizedUserMessage
    }
}
```

### Check Entitlement

```swift theme={null}
// Check if user has pro access
let state = await paymentsClient.currentState()
if state.isSubscribed {
    // Show premium features
} else {
    // Show paywall
}
```

## Paywall UI

Beautiful paywall included in FeatureSettings (`PaywallView`). Pass the shared
`PaymentsClient`; the view creates its own `PaywallViewModel`:

```swift theme={null}
// Illustrative paywall presentation — SettingsView triggers `showPaywall`
// and presents PaywallView in a sheet:
if !viewModel.isSubscribed {
    Button("Go Premium") {
        showPaywall = true
    }
}
.sheet(isPresented: $showPaywall) {
    PaywallView(paymentsClient: viewModel.paymentsClientAccessor) {
        showPaywall = false
    }
}
```

## Customization Examples

### Add New Subscription Tier

The paywall renders one `PlanOptionCard` per offering returned by
`getOfferings()`, so adding a tier is mostly a RevenueCat dashboard task — no UI
code needed for the option to appear:

```swift theme={null}
// 1. Create the product in App Store Connect
// 2. Add it to your RevenueCat offering / package
// 3. PaywallViewModel.offerings picks it up; PaywallView renders a
//    PlanOptionCard for it automatically:
ForEach(viewModel.offerings) { offering in
    PlanOptionCard(
        offering: offering,
        isSelected: viewModel.selectedOffering?.id == offering.id,
        onSelect: { viewModel.selectOffering(offering) }
    )
}
```

### Add Usage Limits

Gate free-tier usage on entitlement state. The daily-count check below is a sketch —
the boilerplate's `MessageRepository` does not ship a `todayCount()`; supply your own
counter (e.g. a `@AppStorage` tally or a `page(...)` query you filter by date):

```swift theme={null}
// Pattern: gate a free-tier limit behind the pro entitlement
func canSendMessage(todayCount: () async -> Int) async -> Bool {
    let state = await paymentsClient.currentState()

    if state.isSubscribed {
        return true  // Unlimited for pro
    } else {
        return await todayCount() < 20  // Your own free-tier limit
    }
}
```

### Custom Entitlements

```swift theme={null}
// Multiple entitlements
if state.activeEntitlementIDs.contains("pro") {
    // Pro features
}

if state.activeEntitlementIDs.contains("premium_support") {
    // Priority support
}
```

## Testing

### Sandbox Testing

1. **Create sandbox tester** in App Store Connect
2. **Sign in** on device with sandbox account
3. **Test purchases** - all free in sandbox
4. **Test restore** - verify works correctly

### Mock Client

`PreviewMocks.MockPaymentsClient` conforms to the full `PaymentsClient` protocol
and returns sample offerings, so previews and UI tests run with no App Store
Connect setup:

```swift theme={null}
final class MockPaymentsClient: PaymentsClient, @unchecked Sendable {
    func configure(_ config: PaymentsConfig) {}
    func states() -> AsyncStream<PaymentsState> { /* yields .free */ }
    func currentState() async -> PaymentsState { PaymentsState(isSubscribed: false) }
    func purchase(productID: String) async throws {}
    @discardableResult
    func restore() async throws -> PaymentsState { PaymentsState(isSubscribed: false) }
    func prefetchOfferings() async {}
    func getOfferings() async throws -> [PaymentsOffering] { /* sample monthly + annual */ }
}
```

## Key Files

| Component  | Location                                                                   |
| ---------- | -------------------------------------------------------------------------- |
| Protocol   | `Packages/Payments/Sources/Payments/Protocols/PaymentsClient.swift`        |
| RevenueCat | `Packages/Payments/Sources/Payments/RevenueCat/`                           |
| Paywall UI | `Packages/FeatureSettings/Sources/FeatureSettings/Views/PaywallView.swift` |

## Dependencies

* **Core** - Error handling, logging

## Used By

* **FeatureSettings** - `PaywallView` / `PaywallViewModel` and subscription management
* **App shell** - `ProfileView` reads subscription status
* **Any feature** - Entitlement checking via `currentState()` / `states()`

## Best Practices

<AccordionGroup>
  <Accordion title="Purchase Flow">
    * Show clear pricing
    * Include terms and privacy links
    * Handle cancellation gracefully
    * Provide restore option
    * Test thoroughly in sandbox
  </Accordion>

  <Accordion title="Entitlements">
    * Check server-side (if possible)
    * Cache locally for offline
    * Update on app launch
    * Observe state changes
  </Accordion>

  <Accordion title="UX">
    * Make paywall beautiful
    * Highlight value proposition
    * Show feature comparison
    * Easy to dismiss
    * Clear cancellation policy
  </Accordion>
</AccordionGroup>

## Learn More

<CardGroup cols={2}>
  <Card title="Full Documentation" icon="book" href="https://github.com/SwiftAIBoilerplatePro/SwiftAIBoilerplatePro-Distribution/blob/main/docs/modules/Payments.md">
    Complete Payments guide
  </Card>

  <Card title="RevenueCat Setup" icon="dollar" href="/pages/guides/revenuecat-setup">
    Configuration guide
  </Card>

  <Card title="Feature Settings" icon="gear" href="/pages/modules/feature-settings">
    See paywall UI
  </Card>

  <Card title="Building Guide" icon="hammer" href="/pages/guides/building-your-app">
    Customize subscriptions
  </Card>
</CardGroup>

## Test Coverage

**85%+** for the Payments package (payments are critical). The `PaymentsTests`
target runs as part of the full suite (**\~598 tests across 12 package test targets
plus the app test suites**, one `Boilerplate.xctestplan` run).

Tests include (`PaymentsFlowTests`, `RevenueCatClientTests`, `PaymentErrorScenarioTests`):

* Purchase flows
* Restore purchases
* Entitlement checking
* State management
* Error scenarios
* Subscription expiry

## Build with AI (fast)

You can customize this module in minutes using our ready-to-paste LLM prompts.

### Example Prompt

Context: `Packages/FeatureSettings/**`
Prompt:
"Add a toggle in Settings to show/hide a discounted annual plan on the paywall. Update tests to verify pricing visibility."

See in project: `docs/modules/Payments.md`
