Issue #794

I’ve been distributing my apps PastePal and Push Hero as a paid upfront apps on Appstore. I’ve quickly come to realize the importance of trials since many have requested a tryout before purchasing.

Manual sending out trials comes with its own problem. I need to notarize and package the app as a dmg file, and not to even mention implement my own trial mechanism to check and notify of expired builds.

This is my attempt to firstly convert Push Hero from paid to freemium. I’ve done StoreKit before and I always want to reduce dependencies as much as possible. You can read my posts about StoreKit here

I’m somewhat OK with StoreKit and receipt validation, but since many have recommended RevenueCat I’ve decided to give it a shot, since the real-time transactions and its offering system sound intriguing

Setting up

For now to integrate and configure the SDK, visit the docs Configuring the SDK

I like that the SDK purchase-ios is open source and that I can easily import it via Swift Package Manager.

Next is to create a new project on RevenueCat. There we can configure offering, entitlements, and products. Read Configuring Products

abc

I have to admit these terms are bit overhead, but since RevenueCat works for both iOS, Android and Stripe, I understand the need for a proper abstraction. Here are my understanding

  • Product: is the equivalent of SKProduct. In code, we refer to these as Package.
@interface RCPackage : NSObject
@property (readonly) NSString *identifier;
@property (readonly) RCPackageType packageType;
@property (readonly) SKProduct *product;
  • Entitlement: used to ensure a user has appropriate access to content based on their purchase. In Push Hero I have a “pro” entitlement and attach the Pro product. Entitlement is part of PurchaseInfo, we usually check active to check if user has “pro” plan
@interface RCPurchaserInfo : NSObject
@property (nonatomic, readonly) RCEntitlementInfos *entitlements;
@end

@interface RCEntitlementInfos : NSObject
@property (readonly) NSDictionary<NSString *, RCEntitlementInfo *> *all;
@property (readonly) NSDictionary<NSString *, RCEntitlementInfo *> *active;
@end
  • Offering: a group of products. This is good for A/B testing and seasonal promotions where we can choose which offering is current and that should be displayed to users at paywall.

Information in ViewModel

I use SwiftUI, and it’s great to have an ObservableObject as ViewModel so it can be updated in ProManager, while display content to View.

This is a simplified version of my ViewModel, but here is the necessary information. The flag isPro is used to control feature access, and it will be updated based on purchasing logic. The Settings is used to configure API key, and the version check point when we made the transition from paid to freemium

import SwiftUI
import Purchases

final class ProInfo: ObservableObject {
    struct Settings {
        let revenueCatApiKey: String
        let startFreemiumMacVersionNumber: String
        let startFreemiumiOSBuildNumber: Int
    }
    
    @Published
    var isPro: Bool = false
    
    @Published
    var package: Purchases.Package?
    
    @Published
    var isPurchasing: Bool = false
    
    @Published
    var isRestoring: Bool = false
    
    @Published
    var error: String?
    
    let settings = Settings(
        revenueCatApiKey: "",
        startFreemiumMacVersionNumber: "2.2.0",
        startFreemiumiOSBuildNumber: 20
    )
 }

When we start our app, we should initialize a ProManager that is used to drive our in app purchase logic

final class ProManager: ObservableObject {
    private let proInfo: ProInfo
    
    init(proInfo: ProInfo) {
        self.proInfo = proInfo
        Purchases.configure(withAPIKey: proInfo.settings.revenueCatApiKey)
        Purchases.shared.purchaserInfo { (purchaserInfo, error) in
            self.handle(purchaseInfo: purchaserInfo)
        }
    }
}

When use bought our app, a local receipt will be available. It has originalApplicationVersion that we can use to check if user has bought our app before we made the transition, or after. Remember that this originalApplicationVersion is the build number (in iOS) or the marketing version (in macOS) for the version of the application when the user bought the app.This corresponds to the value of CFBundleVersion (in iOS) or CFBundleShortVersionString (in macOS) in the Info.plist file when the purchase was originally made

PurchaseInfo parses and presents the local receipt in a nice way, so we can just check. Remember that Swift has a nice String.compare options numeric that we can use to compare version strings in macOS. For example

"2.1.1".compare("2.2.0", options: .numeric) == .orderedAscending
func handle(purchaseInfo: Purchases.PurchaserInfo?) {
    if let originalApplicationVersion = purchaseInfo?.originalApplicationVersion {
        proInfo.isPro = isPaid(originalApplicationVersion)
    } else {
        proInfo.isPro = false
    }
}

func isPaid(_ originalAppVersion: String) -> Bool {
    #if os(OSX)
    return originalAppVersion.compare(
        proInfo.settings.startFreemiumMacVersionNumber,
        options: .numeric
    ) == .orderedAscending
    #else
    let originalBuildNumber = Int(originalAppVersion) ?? 0
    return originalBuildNumber < proInfo.settings.startFreemiumiOSBuildNumber
    #endif
}

Display products

You may have many offerings but in Push Hero for now there is only one, and it is set as current. So display products, I fetch the current offering for a lifetime package which is my Non-Consumable one-time purchase. ProInfo is an ObservableObject so the Upgrade UI should be reflected thanks to SwiftUI and Combine

Read more Displaying Products

func fetchProducts() {
    Purchases.shared.offerings { (offerings, error) in
        if let package = offerings?.current?.lifetime {
            self.proInfo.package = package
        }
    }
}

Make a purchase and Restore purchase

We check the current package and call purchasePackage to make a purchase. In the completion block we care about purchaseInfo and check the entitlements for “pro”

func purchase() {
    guard let package = proInfo.package else { return }

    self.proInfo.isPurchasing = true
    Purchases.shared.purchasePackage(package) { (transaction, purchaseInfo, error, userCancelled) in
        self.proInfo.isPurchasing = false
        self.parseErrorIfAny(error: error)
        self.handle(purchaseInfo: purchaseInfo)
    }
}

func handle(purchaseInfo: Purchases.PurchaserInfo?) {
    if purchaseInfo?.entitlements["pro"]?.isActive == true {
        proInfo.isPro = true
    }
}

The local receipt might be unavailable when the user syncs or restore devices, or when they redeem the same universal purchase on another device. So a restore purchase must be present. Trigger this asks for user iCloud account

Read more Making Purchases

func restorePurchase() {
    self.proInfo.isRestoring = true
    Purchases.shared.restoreTransactions { (purchaseInfo, error) in
        self.proInfo.isRestoring = false
        self.parseErrorIfAny(error: error)
        self.handle(purchaseInfo: purchaseInfo)
    }
}

Handle error

RevenueCat has a good list of known error codes so you should parse and present failure information to user so they know how to troubleshoot. Read more Error Handling

Now that’s the essential logic, your part is to make a nice Upgrade UI and also thank you confetti for when user has successfully make a purchase.