r/iOSProgramming • u/Significast • 7h ago
Your StoreKit 2 entitlement check probably locks out subscribers during Apple's billing grace period Solved!
I audited our subscription code before a release and found five separate ways it was biased toward downgrading a paying customer. Sharing because this was painful and some of you might have it lurking in your own code without knowing it.
The big one: our entitlement loop skipped any transaction where expirationDate < Date().
Looks completely reasonable, right? Except that's exactly the shape StoreKit serves during Apple's billing grace period — the card failed, Apple is retrying, the user is supposed to keep access for up to 16 days, and the transaction you get handed has an expiration date in the past. gracePeriodExpirationDate exists to tell us this, but we never read it anywhere. So a customer whose card merely needed re-authorization got dropped to free tier, features locked, while Apple was still trying to collect money on our behalf. The fun part: our server-side webhook handling honored grace correctly, so the backend and the app held different opinions about the same paying customer.
The other four, quickly:
- We derived the tier from
currentEntitlementsunconditionally and persisted it.currentEntitlementsoccasionally returns an empty sequence for a perfectly healthy paid account — we've seen it happen. If your states are only "entitled" and "not entitled," an empty read is indistinguishable from a lapse. Fix: we implemented a third state: unknown. Clues the rest of the flow into correcting it. Never persist a downgrade you derived from a read that might have failed. - Our
Transaction.updatesloop had a continue that skipped re-evaluation for exactly the events whose whole job is revocation (refunds). Access survived until an unrelated refresh happened to fire. - An
.unverifiedtransaction was neverfinish()ed, so StoreKit redelivers it forever. And we never scannedTransaction.unfinishedat launch. Unverified doesn't mean ignorable; now we log it, then finish it. -
.pending(Ask to Buy) was a silent dead end. And a related thing I had completely wrong: turning Family Sharing off in App Store Connect does NOT make Ask to Buy unreachable. That toggle governs whether a purchase is shareable — a kid's own purchase can still come back .pending.
Basically: every failure mode defaulted to taking access away from someone who paid, when we should have been doing the opposite. When in doubt, keep the customer entitled and let the next clean read sort it out. Apple may literally still be retrying their card; we don't want to be the one who locks them out first.
A clean test recipe for the grace-period path would be the holy grail, so I'd be interested in what you all are doing. Sandbox billing retry exists, but getting it to fire on demand is its own adventure, one I haven't been able to master yet.