A fleet-wide payments audit turned up one defect that I then fixed across 8 iOS apps. The paywall's "7-day free trial" badge was showing whenever the product had a trial offer configured — unconditionally.
The consequence is simple. A user who had already used their trial saw "Start free", tapped it, and was charged immediately.
One question. What is your paywall's trial badge actually driven by right now — the product's configuration, or that account's eligibility?
1. Those are two different facts
storeProduct.introductoryDiscount is product configuration. It only tells you that this
product has an introductory offer attached. It says nothing about whether this account has
already consumed it.
Account eligibility is only knowable from a separate lookup (check intro/trial eligibility). We were not calling it.
Same family as the free trial that was really a price and the paywall that sold a feature that wasn't there: paywalls promising something that isn't true.
2. The source of truth is one pure function
static func trialEligibleIDs(_ statuses: [(String, IntroEligibilityStatus)]) -> Set<String> {
Set(statuses.lazy.filter { $0.1 == .eligible }.map(\.0))
}Advertise .eligible only. A failed determination (.unknown) and "no offer" both advertise
nothing. If you don't know the eligibility, not promising is the safe failure. Lookup errors,
unconfigured SDKs, and empty packages all produce the empty set.
The direction of failure matters here. This gate is a filter placed in front of a badge that used to always show. If I wire it wrong, the only possible outcome is "badge missing" (over-suppression); "shown without eligibility" is structurally impossible. So the residual risk is lost revenue, not misrepresentation. Checking that direction first made the ship decision much easier.
3. Fixing one place really does leave others behind
Sweeping all 8 apps, the same defect was alive in several copies.
- One app had a direct store-API fallback path in addition to the payment SDK offering path, and it built its own caption. Worse, deleting the row's caption still leaves the terms disclosure promising a trial. I branched the disclosure and added a trial-free variant in 5 locales.
- One app returned
truewhen packages hadn't loaded yet. The comment said "default CTA = free trial". That is fail-open advertising of a trial before the store has answered.
The sturdiest shape belonged to the app that split the field in two:
offeredTrialDays // does an offer exist? (product configuration)
isTrialEligible // can this account have it? (account eligibility)
freeTrialDays // computed — all 3 UI sites are gated in this one placeThe rest use an early-return gate inside a function, which leaks again the moment a new call site appears. Split into two names and the next person cannot pick the wrong one.
4. Where I deliberately did not fix, and why
Fixing everything isn't always right.
- Terms text like "any unused portion of the trial is forfeited" is a conditional legal disclosure, not an eligibility claim, and splitting that sentence would destroy 16 locales of translation for that key.
- Post-purchase notifications describing a trial the user already has were also left alone.
5. How would you verify this ship?
The gate is in. Now it needs a real device — but on an eligible account the badge shows exactly as before, which proves nothing. What you need is an ineligible account.
- (a) Create a new sandbox account and burn its trial
- (b) Find out whether an already-burned account happens to exist
- (c) Use the simulator instead
(c) is out — the simulator returns .unknown for eligibility. I got (b): that device's sandbox
account had consumed another app's annual trial the day before. It was the only environment
in the fleet where "ineligible → badge hidden" was observable. Result: no badge on the annual row.
⚠️ But "no badge" is not evidence on its own. It could be missing because the product never rendered. I separately opened the device's payment SDK cache to confirm the offering actually loaded (both monthly and annual products were mapped). So: two products rendered, badge absent. Without that check I would have been calling an empty screen a success.
6. One side effect, and the call I made
With the gate in place, the simulator returns .unknown, so every paywall screenshot I capture
from now on loses the badge.
The call: keep the badge in store screenshots. The audience for those screenshots is new users, and they really do see the badge. Removing it would under-represent the actual experience. The bypass goes not into the gate but into the point that populates the eligibility set (a capture mode enabled only by a launch argument).
⚠️ Along the way one comment made me reason wrongly. All 8 apps carry a comment saying "the payment SDK is not configured in capture mode". It isn't true — it is configured, and offerings load. Believing that sentence, I concluded "the packages will be empty so the bypass won't work", and I was wrong. Comments are things to verify too.
7. What I overstated
My first note said "7 apps remain because the wiring differs per app". Diffing the code, all 8 apps were the same single statement. That was an exaggeration.
Remaining limits, stated plainly:
- The gates themselves are mostly
privateand payment SDK types can't be constructed, so there are no view-level unit tests. Only the pure function is tested — in all 8 apps. - I never saw the positive path on a real device (badge showing for an eligible account). That is the pre-existing behavior in front of the gate, but it's still unverified.
Three incidental finds on the way: one app's UI test target had zero sources, so the scheme's test action always ended in failure even after unit tests passed; one app assembled its trial copy in English in code, never localized at all; and one app's catalog held orphan keys with zero references.
Three things to check in your own code
- Open the conditional that draws your trial badge. If it only reads fields off the product object, that badge does not know the account.
- What do you draw before packages arrive? If it's
return true, you're promising before the store has answered. - Do the terms and disclosures on that same screen also mention a trial? Delete the badge and the sentence survives.
The honest part
This defect is less "the code was wrong" and more two different concepts sharing one name.
"An offer exists" and "this person can have it" are different facts, and the variable was called
hasFreeTrial for both. When there's one name, the code treats it as one thing.
Do one thing today. Grep your paywall for every identifier containing "trial", then count how many of them refer to the product and how many refer to the account. I only split the field after I counted.