Never Gate Access on willRenew
Published 17 September 2026
Quick answer:
willRenew == falsemeans the subscription won't renew, not that it has ended. A user who cancels auto-renew keeps access untilexpirationDate. Gate onexpirationDate == nil || expirationDate > now || willRenew— or simply trust the SDK's own "is this entitlement active" answer, which already accounts for it.
This is a bug I shipped, and a user found it before I did.
// Wrong.
var isPaid: Bool {
entitlement.willRenew
}
It reads correctly. It passes review. Every test you'd write for it passes, because in the sandbox and in normal use willRenew is true for an active subscriber.
Then someone turns off auto-renew.
What actually happens when someone cancels
Turning off auto-renew doesn't end a subscription. It schedules it not to continue. The user has paid for a period and keeps full access until that period ends — that might be three weeks away.
So the state is: active, paid, entitled, willRenew == false.
With the check above, the app locks them out the instant they cancel. From their side: they paid for a month, decided not to continue, and were immediately denied the thing they'd already paid for. That's a refund request and a one-star review, and both are deserved.
It also hits people who never meant to cancel — auto-renew switches off for billing problems, expired cards, and users tidying up their subscriptions list without realising what it does.
The correct check
If you're working with raw values:
var isPaid: Bool {
if let expiry = entitlement.expirationDate {
return expiry > Date() || entitlement.willRenew
}
return true // no expiry — lifetime or non-consumable
}
Three cases, and each matters:
- No expiry date → a lifetime purchase or non-consumable. Always unlocked.
- Expiry in the future → paid access, regardless of renewal intent. This is the case the buggy version got wrong.
- Expiry in the past but
willRenew→ in a billing retry grace period. Apple is still trying to charge them, and cutting access mid-retry punishes someone whose card just expired.
If you're using RevenueCat, the simpler answer is that entitlements.active already means valid access. It accounts for all three cases. Reaching past it to inspect willRenew yourself is how the bug gets written in the first place — the SDK had already answered the question correctly.
Why testing doesn't catch it
The happy path is overwhelmingly the common one, so it's what gets exercised. Reproducing this requires deliberately cancelling a sandbox subscription and then checking the app still works, which nobody thinks to do because the mental model is "cancelled means over".
Worth adding as an explicit case:
@Test func cancelledButUnexpiredIsStillPaid() {
let entitlement = Entitlement(
expirationDate: Date().addingTimeInterval(60 * 60 * 24 * 20),
willRenew: false // user turned off auto-renew
)
#expect(entitlement.isPaid)
}
The wider lesson
The bug came from treating a forward-looking flag as a current-state flag. willRenew describes the future. Entitlement is about now.
Same trap shows up elsewhere in purchase code — treating "in grace period" as unpaid, treating "billing issue" as expired, treating a refund request as already refunded. Each is a signal about what might happen, being read as what has happened.
The safe default when the state is ambiguous is to keep the user unlocked. Wrongly granting access for a few days costs you very little. Wrongly denying it to someone who paid costs a refund, a review, and a person who won't come back.
If you have shipped subscription code that inspects renewal flags directly, it's worth ten minutes to go and look at it. This class of bug produces no crash, no log line, and no analytics event — the only signal is a support email from someone who assumes it's their fault.
Related posts
- Your Vision OCR Accuracy Depends on the OS, Not Your Code VNRecognizeTextRequest's default revision drifts between iOS versions, and pinning a legacy revision no longer restores the old behaviour. What that does to a frozen benchmark.
- Mach Error -308 and the Shared Simulator Problem Parallel test runs that share a simulator die with 'Mach error -308 (server died)'. Why simctl erase then fails with Invalid argument, and why simctl shutdown all makes it worse.
- Your App's Locale Codes Aren't Fastlane's Locale Codes xcstrings uses de and fr. fastlane/metadata needs de-DE and fr-FR, and a bare directory fails the lane hard. The mapping, and how to check it before a release.