Last Week

Last week, my focus was on laying the groundwork for monetizing the app using subscriptions. Specifically, I set up the data layer components for integrating RevenueCat. This means I’ve added the necessary code structure (like repositories) to interact with RevenueCat’s services, but haven’t yet built the user interface elements (like a paywall) or connected the actual purchase flows.

RevenueCat is a service that simplifies handling in-app subscriptions across different platforms like iOS and Android. Instead of building complex logic myself to manage different subscription plans, track user entitlements (what features a user has paid for), handle renewals, and interact directly with the nuances of both the Google Play Store and Apple App Store billing systems, RevenueCat provides a unified backend and SDK to manage most of this complexity.

I explored different monetization options:

  1. Ads: While common, I find them intrusive and detrimental to the user experience I want to provide.
  2. One-time purchase: This works well for apps without ongoing server costs. However, my app relies on backend services that incur costs per use, making a one-time fee unsustainable. I’d quickly go out of business if users paid once but used the services indefinitely.
  3. Subscriptions: Given the ongoing costs associated with my app’s backend services, a recurring subscription model is the most viable approach. It allows me to cover those costs and align the price with the value delivered over time.

Building a robust subscription system yourself is incredibly complex, involving database management for entitlements, handling various payment states, managing promotions, and dealing with platform-specific billing APIs. RevenueCat abstracts away much of this difficulty, allowing me to focus more on the app’s core features. It handles tracking subscription status, managing renewals, and even provides tools for server-side configuration of paywalls, which simplifies rolling out promotions or price changes without requiring an app update. While RevenueCat isn’t free (though it has a generous free tier up to $2,500/month in tracked revenue), the cost is well worth the development time and complexity it saves.

So, the work last week involved integrating the RevenueCat SDK and setting up the repository pattern within the app’s data layer to encapsulate all interactions related to fetching offerings, customer information, and initiating purchases.

What does it mean in English?

Imagine you’re opening a coffee shop that offers a monthly coffee subscription. Instead of building your own complicated cash register system from scratch to track who signed up, when their subscription renews, handling payments through different banks (like Apple Pay vs. Google Pay), and remembering what kind of coffee they subscribed to, you decide to use a specialized service that handles all the subscription and payment logistics for you.

That service is RevenueCat in the context of my app. Last week, I essentially set up the connection between my app (the coffee shop) and this subscription management service (RevenueCat). I’ve laid the “wiring” so the app can talk to RevenueCat, but I haven’t put up the “Subscribe Now!” sign or started actually selling the coffee subscriptions to customers yet. That’s the next step. This initial setup ensures that when I do start offering subscriptions, the complicated backend part is managed reliably.

Nerdy Details

Setting up the foundation for RevenueCat involves integrating their SDK and creating a data layer abstraction, typically using the repository pattern. This encapsulates RevenueCat logic and provides a clean interface for the rest of your app (like ViewModels). Here’s a basic example using Kotlin for an Android app:

1. Add Dependencies:

First, add the RevenueCat Purchases SDK dependency to your app-level build.gradle.kts (or build.gradle) file:

// build.gradle.kts (app level)
dependencies {
    implementation("com.revenuecat.purchases:purchases:7.9.1") // Check for the latest version
    // ... other dependencies
}

(Remember to sync your project with Gradle files)

2. Configure the SDK:

Initialize the RevenueCat SDK when your application starts. The Application class is a common place for this. You’ll need API keys from your RevenueCat dashboard. It’s best practice to not hardcode these directly. Use BuildConfig fields or secure storage.

// MyApplication.kt
import android.app.Application
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // Configure Purchases SDK
        Purchases.logLevel = LogLevel.DEBUG // Use DEBUG for development, WARN or ERROR for production
        val builder = PurchasesConfiguration.Builder(this, "YOUR_GOOGLE_API_KEY") // Replace with your key
            // If targeting Amazon Appstore, uncomment the line below
            // .store(Store.AMAZON)
            // If using your own App User ID, uncomment the line below
            // .appUserID("YOUR_APP_USER_ID")
            .build()
        Purchases.configure(builder)

        // Optional: Set up user identity if your app has user accounts
        // loginUser("your_internal_user_id")
    }

    // Example function for setting App User ID after login
    fun loginUser(appUserId: String) {
        Purchases.sharedInstance.logInWith(()=>{
            // Handle login success - maybe refresh offerings or customer info
        }, { error ->
            // Handle login error
        })
    }

    // Example function for clearing App User ID on logout
    fun logoutUser() {
        Purchases.sharedInstance.logOutWith(()=>{
            // Handle logout success
        }, { error ->
            // Handle logout error
        })
    }
}

Remember to register MyApplication in your AndroidManifest.xml: <application android:name=".MyApplication" ... >

3. Create a Repository Interface:

Define an interface for your subscription-related data operations. This promotes loose coupling and testability.

// SubscriptionRepository.kt
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.models.StoreTransaction

// Define result wrappers for better error handling
sealed class PurchaseResult {
    data class Success(val storeTransaction: StoreTransaction, val customerInfo: CustomerInfo) : PurchaseResult()
    data class Error(val message: String, val underlyingErrorMessage: String?) : PurchaseResult()
    object UserCancelled : PurchaseResult()
}

interface SubscriptionRepository {
    // Fetch available subscription offerings (products/packages)
    suspend fun getOfferings(): Result<Offerings>

    // Get current customer subscription status and entitlements
    suspend fun getCustomerInfo(forceRefresh: Boolean = false): Result<CustomerInfo>

    // Initiate a purchase for a specific package
    // Note: The purchase flow requires an Activity context, often passed from the ViewModel/UI layer
    suspend fun purchasePackage(activity: Activity, packageToPurchase: Package): PurchaseResult

    // Restore purchases (useful for users reinstalling or using a new device)
    suspend fun restorePurchases(): Result<CustomerInfo>

    // Optional: Check if a user is entitled to a specific feature
    suspend fun isEntitledTo(entitlementIdentifier: String): Boolean
}

Note: Using a Result wrapper (like Kotlin’s built-in Result or a custom one) is recommended for handling potential errors gracefully.

4. Implement the Repository:

Create a concrete implementation of the interface using the RevenueCat SDK. Use coroutines for asynchronous operations.

// RevenueCatSubscriptionRepository.kt
import android.app.Activity
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesException
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore
import com.revenuecat.purchases.purchaseWith // Recommended way
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton // Example using Hilt/Dagger for singleton scope
class RevenueCatSubscriptionRepository @Inject constructor() : SubscriptionRepository {

    private val purchases = Purchases.sharedInstance

    override suspend fun getOfferings(): Result<Offerings> = withContext(Dispatchers.IO) {
        try {
            Result.success(purchases.awaitOfferings())
        } catch (e: PurchasesException) {
            Result.failure(e)
        }
    }

    override suspend fun getCustomerInfo(forceRefresh: Boolean): Result<CustomerInfo> = withContext(Dispatchers.IO) {
         try {
             // Note: RevenueCat SDK caches CustomerInfo. Use awaitCustomerInfo() for potentially cached data
             // or fetch specific fields if needed. Forcing refresh isn't commonly needed unless syncing issues arise.
             // The SDK automatically fetches updates in many scenarios.
             // If explicit refresh is truly needed, you might handle cache policy, but start simple.
             Result.success(purchases.awaitCustomerInfo())
         } catch (e: PurchasesException) {
             Result.failure(e)
         }
    }

    override suspend fun purchasePackage(activity: Activity, packageToPurchase: Package): PurchaseResult = withContext(Dispatchers.Main) {
        // Purchases must be initiated on the main thread and require an Activity
        try {
            val purchaseParams = PurchaseParams.Builder(activity, packageToPurchase).build()
            val (storeTransaction, customerInfo) = purchases.awaitPurchase(purchaseParams)

            // Optional: Check transaction details if needed
            if (customerInfo.entitlements.active.isNotEmpty()) {
                // Purchase successful and entitlement granted
                 PurchaseResult.Success(storeTransaction, customerInfo)
            } else {
                 // This case might indicate an issue or delayed propagation
                 PurchaseResult.Error("Purchase completed but entitlement not immediately active.", null)
            }
        } catch (e: PurchasesException) {
            if (e.userCancelled) {
                PurchaseResult.UserCancelled
            } else {
                PurchaseResult.Error("Purchase failed: ${e.message}", e.underlyingErrorMessage)
            }
        }
    }

     override suspend fun restorePurchases(): Result<CustomerInfo> = withContext(Dispatchers.IO) {
         try {
             Result.success(purchases.awaitRestore())
         } catch (e: PurchasesException) {
             Result.failure(e)
         }
     }

    override suspend fun isEntitledTo(entitlementIdentifier: String): Boolean = withContext(Dispatchers.IO) {
        try {
            purchases.awaitCustomerInfo().entitlements[entitlementIdentifier]?.isActive == true
        } catch (e: PurchasesException) {
            // Log error, potentially treat as not entitled
            false
        }
    }
}

5. Dependency Injection (Recommended):

Use a framework like Hilt or Koin to provide the SubscriptionRepository instance to your ViewModels or other components that need it. This makes your code more modular and testable.

Example with Hilt:

// AppModule.kt (Example Hilt Module)
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindSubscriptionRepository(
        revenueCatSubscriptionRepository: RevenueCatSubscriptionRepository
    ): SubscriptionRepository
}

// YourViewModel.kt
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class MyViewModel @Inject constructor(
    private val subscriptionRepository: SubscriptionRepository
) : ViewModel() {
    // Use subscriptionRepository here...
}

This setup provides a solid data layer foundation. The ViewModels can then call methods on the SubscriptionRepository (like getOfferings to display plans or purchasePackage when a user clicks buy) without needing direct knowledge of the RevenueCat SDK implementation details.

Next Week

Now that the data layer foundation for RevenueCat is in place, the next logical step is to build the user-facing components. This involves:

  1. Implementing the UI for the paywall: Designing and coding the screen where users can see the available subscription plans and their prices.
  2. Fetching and displaying offerings: Using the SubscriptionRepository to get the current subscription packages configured in RevenueCat and displaying them on the paywall UI.
  3. Handling the purchase flow: Connecting the “Subscribe” buttons on the paywall to the purchasePackage function in the repository, managing the UI state during the purchase process (e.g., showing a loading indicator), and reacting to success, cancellation, or failure.
  4. Checking entitlements: Using the getCustomerInfo or isEntitledTo methods to determine the user’s subscription status and unlock/lock premium features within the app accordingly.
  5. Implementing purchase restoration: Adding a “Restore Purchases” button or mechanism, typically in the app’s settings, calling the restorePurchases function.