Go and look at what your entitlement code does when the store is unreachable. Not what it is supposed to do. What the error branch actually does, line by line. I did that on 11 August because a different app of mine had a suspicious symptom, and I found that Fingertips, the app I am building for Shipaton, had a worse version of it. A 14-day trial that never expired. Not "expired late". Never. Launch once without a network, and the app was unlocked forever. It was not a subtle bug once I saw it. It was four lines, and every one of them was written on purpose. The policy that caused it is the correct policy Here is the state, and I still think this enum is right: enum State: Equatable { case unknown case trial(daysLeft: Int) case expired case purchased var isUnlocked: Bool { switch self { case .purchased, .trial: true // `unknown` unlocks deliberately: a StoreKit or network hiccup must // never lock someone out of an app they have paid for. Failing open // costs us a few unpaid launches; failing closed costs a 1-star review. case .unknown: true case .expired: false } } } .unknown means "I could not ask". Not "no purchase". Those are genuinely different, and conflating them is how you lock a paying customer out of the app they bought because they opened it on hotel wifi. Every entitlement guide tells you to fail open here, and every one of them is right. Failing open is a good default. What I had not noticed is that a default with no boundary is not a default. It is the whole behaviour. The path refresh() asks RevenueCat whether the pro entitlement is active. hasActivePurchase() returns Bool?, where nil specifically means the question could not be asked: /// nil when RevenueCat could not be reached at all, which is different from /// a definite "no purchase". private func hasActivePurchase() async -> Bool? { do { let info = try await Purchases.shared.customerInfo() return info.entitlements[Self.entitlementID]?.isActive == true } catch { return nil } } And here is the code that consumed it. This is the version that shipped: if isConfigured { let active = await hasActivePurchase() if active == true { state = .purchased await loadPrice() return } if active == nil { if state != .purchased { state = .unknown } await loadPrice() return // app.fingertips -trialAnchorDaysAgo 20 # broken: expired trial, store unreachable xcrun simctl launch app.fingertips -trialAnchorDaysAgo 20 \ -simulateOfflineStore First command: the paywall, correctly. Second command: the entire app, library and all, for a trial that lapsed six days ago. You do not need my flags to check your own app. You need any way to reach two states at once, an entitlement your backend would refuse and a backend you cannot reach. Turning off wifi covers half of it. The expired half is the one you will have to build a switch for, and building that switch is most of the work. The fix is one flag The question the code could not answer was: is this someone who has paid, or someone who has never paid? Failing open is obviously right for the first and obviously wrong for the second, and .unknown was treating them identically. So I persisted the one bit that separates them: /// Has RevenueCat ever told us this Apple ID owns the app? /// /// With it the two cases separate. Someone we have seen buy stays unlocked /// offline forever; someone we have never seen buy falls through to the /// trial like anybody else. Persisted, because the launch that learns it is /// not the launch that needs it. private static let everPurchasedKey = "everPurchased" private static var everPurchased: Bool { get { UserDefaults.standard.bool(forKey: everPurchasedKey) } set { UserDefaults.standard.set(newValue, forKey: everPurchasedKey) } } Set in exactly one place, the moment RevenueCat confirms a purchase: if newValue == .purchased { Self.everPurchased = true defaults?.removeObject(forKey: SnippetCache.unlockedUntilKey) } And the offending line becomes conditional: if active == nil { if state != .purchased { state = .unknown } await loadPrice() // Fail open only for someone we have actually seen buy. This used to // return unconditionally, which meant an unreachable store skipped the // trial check altogether. Everyone else falls through and gets the trial // evaluated on the local anchor, which needs no network at all. if Self.everPurchased || state == .purchased { return } } That is the entire fix. One persisted boolean and a condition on a return. The four cases, all run rather than reasoned about: Trial Store Result expired reachable paywall expired unreachable paywall active unreachable unlocked expired, has purchased unreachable unlocked Row two is the bug. Row four is the reason the fix is a flag rather than deleting the return: a customer who paid, offline, past the trial window, still gets their app. The part that was never broken, and why Fingertips has four surfaces outside the app: a keyboard extension, widgets, App Intents, and the Mac palette. None of them can talk to RevenueCat. Extensions run under a hard memory cap, and a keyboard extension in particular cannot afford to spin up a purchases SDK before it draws. So the app mirrors its verdict into the shared app group as a flag plus a deadline, and the extensions read that: static var isUnlocked: Bool { guard let defaults = UserDefaults(suiteName: appGroupID) else { return true } guard defaults.object(forKey: unlockedKey) as? Bool ?? true else { return false } guard let until = defaults.object(forKey: unlockedUntilKey) as? Date else { return true } return .now < until } Not one of those four surfaces had the bug. They kept expiring correctly through the whole thing, because they never asked a network anything. They read a date and compared it to now. I wrote that design for a completely different reason, which is that the keyboard must work when the app has not been opened in a month. It happened to be immune to a failure I had not thought of yet, and it was immune for a structural reason: it had no error branch to get wrong. There is no "could not ask" case in "is now before this date". That is the strongest argument I know for pushing a decision down to the simplest representation you can persist. The version that cannot fail in an interesting way usually cannot fail in an uninteresting one either. What to take away Three things, in the order I would check them in your codebase this afternoon. Every catch in an entitlement check is a business decision. Write down which side it fails to, and who is allowed through it. "Fail open" is not a policy until you have named the population it applies to. Mine said everyone, and meant it. Check whether your safety nets are independent. Mine looked like two layers and behaved like one, because the second was reachable only through code the first failure had skipped. Grep for what populates the state your backstop guards on. If the answer sits below the early return, you have one net. Ask how long your process lives. Anything you do once per launch, you do once per process, and a menu bar app or a background service can hold a wrong answer for weeks. On iOS this bug had a natural expiry. On macOS it did not. I found this one because another of my apps had the same symptom and I went looking for the pattern rather than the instance. That turned out to be the useful move. If you are shipping a trial on top of RevenueCat, the specific bug may not be yours. The shape of it probably is: a correct default, applied without a boundary, on a branch you have never once executed. Fingertips is a snippet library for iPhone, iPad and Mac, built for RevenueCat's Shipaton 2026. It is at usefingertips.com.
One Early Return Turned My 14-Day Trial Into a Free Licence
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.