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
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 asPackage
.
@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 ofPurchaseInfo
, we usually checkactive
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.