Fix free trial shown for upgrades within an active subscription group#480
Conversation
A paywall could advertise a free trial that Apple never granted. StoreKit's `isEligibleForIntroOffer` only reflects whether the customer has ever consumed an intro offer in the group, so it returns true for someone who paid for a product in the group but never took a trial. Apple does not apply introductory offers to upgrades, crossgrades, or downgrades, so when such a user bought a different product in a group where they already had an active subscription, the trial was advertised but never applied (they were charged immediately). `ReceiptManager.isFreeTrialAvailable` now also requires that there's no active App Store subscription in the product's subscription group. Once the existing subscription lapses, a fresh purchase is eligible for the trial again. Also removes the never-invalidated StoreKit 2 intro-offer eligibility cache so eligibility is re-queried from StoreKit on each check rather than frozen for the app-process lifetime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — the fix is sound and well-tested. One scope question worth a look before merge.
Reviewed changes — gates free-trial advertising on active subscription state to stop paywalls from showing a trial Apple won't grant on an in-group upgrade/crossgrade/downgrade, plus removes a stale SK2 eligibility cache.
- Gate trial on active in-group subscription —
ReceiptManager.isFreeTrialAvailablenow additionally requires no active App Store subscription in the product'ssubscriptionGroupIdentifier, reading the device snapshot fromLatestDeviceCustomerInfo. - Remove never-invalidated SK2 cache —
SK2ReceiptManagerdrops the process-lifetimesk2IntroOfferEligibilitydict;loadIntroOfferEligibilitybecomes a no-op and eligibility resolves live each call via an injectableresolveIntroOfferEligibility(defaultliveIntroOfferEligibility). - Tests — adds
ReceiptManagerTrialEligibilityTests(6) andSK2ReceiptManagerTests(3), wired into the Xcode project;CHANGELOG.mdupdated.
ℹ️ The .eligible override path does not get the new upgrade gate
The fix lands on ReceiptManager.isFreeTrialAvailable, which the App Store paywall-display path only reaches through the .automatic branch (the default) and through TransactionManager.prepareToPurchase. When a paywall is configured with an explicit .eligible intro-offer override, AddPaywallProducts.checkAppStoreTrialEligibility instead calls hasActiveIntroOffer, which blocks only when an active in-group subscription has offerType == .trial — a paid (non-trial) active subscription in the group does not block it.
The net effect is that the exact reported scenario (active paid lower tier, buying a higher tier in the same group) would still advertise a trial if that paywall uses .eligible rather than the default .automatic. Whether .eligible is meant to mean "force-trust the dashboard, skip the upgrade check" or "still suppress trials Apple won't grant" is a product decision, so this is surfaced for awareness rather than treated as a defect in this PR.
Technical details
# `.eligible` override bypasses the upgrade/crossgrade gate
## Affected sites
- `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:306-314` — `.eligible` branch calls `hasActiveIntroOffer(inSubscriptionGroup:)`.
- `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:341-346` — `hasActiveIntroOffer` predicate requires `offerType == .trial`, so an active *paid* in-group sub does not suppress the trial.
- `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:317-318` — only the `.automatic` branch delegates to `factory.isFreeTrialAvailable(for:)` (the path this PR fixes).
## Required outcome
- Decide whether `.eligible`-configured paywalls should also be gated on an active paid subscription in the product's group. If yes, the same `subscription.store == .appStore && subscription.isActive && subscription.subscriptionGroupId == groupId` check needs to apply in the `.eligible` branch (independent of `offerType == .trial`).
## Open questions for the human
- Is `.eligible` intended as an explicit "trust the dashboard, do not second-guess Apple" escape hatch, in which case the current behavior is by design? Default paywalls use `.automatic`, so the common case is covered either way.ℹ️ Trial gate fails open when the device snapshot is missing
When LatestDeviceCustomerInfo has not been saved yet, storage.get(...)?.subscriptions ?? [] yields an empty list, so the gate cannot block and the trial may still be advertised. This matches the pre-fix behavior (the method previously returned raw eligibility), and presentation blocks on waitForSubsStatusAndConfig which sets the snapshot first, so in practice the window is closed — noting it only because a sibling path (AddPaywallProducts.hasEverHadEntitlement) deliberately fails closed via customerInfo.isPlaceholder, so the two trial-eligibility paths use opposite safe-default conventions.
Technical details
# Fail-open vs fail-closed inconsistency across trial-eligibility paths
## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:264` — `?? []` means "no snapshot" is treated as "no blocking sub" (fail open).
- `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:366-369` — the entitlement path treats a placeholder `customerInfo` as "user has had the entitlement" (fail closed) to avoid falsely offering a trial.
## Required outcome
- No change required for correctness given the `waitForSubsStatusAndConfig` ordering guarantee. If you want defense-in-depth symmetry, the gate could distinguish "snapshot present but empty" from "snapshot absent" and fail closed on the latter.
## Open questions for the human
- Is the ordering guarantee (snapshot always saved before any paywall reads `isFreeTrialAvailable`) considered strong enough to rely on, or worth hardening here?Claude Opus | 𝕏
…ducts The snapshot-based gate read `LatestDeviceCustomerInfo.subscriptions`, which fails open: that array is always empty on the StoreKit 1 path and before the first device snapshot is saved, so an active same-group subscriber could still see a trial on an upgrade that Apple charges immediately. `loadPurchasedProducts` already fetches the purchased products (which carry the subscription group ID on both StoreKit 1 and 2) and has per-product active state, so it now records the set of subscription groups the user has an active subscription in. That method runs to completion before any paywall opens (config is only marked retrieved after it finishes, and presentation waits for config), so `isFreeTrialAvailable` always reflects current subscription state for both StoreKit versions — no empty-snapshot fail-open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ The new commit cleanly addresses the prior fail-open concern. One minor robustness observation and a test-coverage note inline — both informational, nothing blocking.
Reviewed changes — re-reviewed the single new commit (17b2490) since the prior pullfrog review (297100c), which reworks how the trial gate determines active in-group subscriptions.
- Active-group set now derived from purchases, not the device snapshot —
isFreeTrialAvailableno longer readsLatestDeviceCustomerInfo.subscriptions(empty on StoreKit 1). InsteadloadPurchasedProductscomputesactiveSubscriptionGroupIdsvia the new file-scopedcomputeActiveSubscriptionGroupIds(forActivePurchasesIn:amongProducts:), joining activeonDeviceSnapshot.purchasesto the fetchedstoreProductsby product id and collectingsubscriptionGroupIdentifier. This works for both StoreKit 1 and 2 and directly resolves the prior "fails open on empty snapshot" finding. - Tests reshaped to seed the gate directly —
ReceiptManagerTrialEligibilityTestsnow seedsactiveSubscriptionGroupIdsthrough a new init param rather thanLatestDeviceCustomerInfo; the store-specificnonAppStoreSubscriptionDoesNotBlockcase was replaced with a nil-group case.
ℹ️ computeActiveSubscriptionGroupIds has no direct test coverage
The new active-group derivation is the load-bearing input to the trial gate for both StoreKit 1 and 2, but every ReceiptManagerTrialEligibilityTests case now seeds activeSubscriptionGroupIds directly and bypasses it. The join between active purchases and fetched products — the exact logic the rework introduced — is exercised by nothing.
Technical details
# `computeActiveSubscriptionGroupIds` is untested
## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:366-376` — new file-scoped function with no test.
- `Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift:27-52` — `makeReceiptManager` seeds the result set directly, so the function never runs in tests.
## Required outcome
- Add a test that drives `computeActiveSubscriptionGroupIds` end-to-end: feed a set of `Purchase` (mix of active/inactive) plus `StoreProduct`s carrying group ids, assert the returned set contains only the groups of active purchases. Cover the inactive-purchase-excluded and product-not-in-set cases.Claude Opus | 𝕏
`Product.products(for:)` silently omits IDs it can't resolve (e.g. a subscription the user still holds whose product was removed from App Store Connect), so deriving active subscription groups solely from the fetched products could drop a still-active group and let a trial show on an upgrade. `computeActiveSubscriptionGroupIds` now unions two sources: the active subscription transactions in the snapshot (StoreKit 2 transactions carry the group ID, so this survives a delisted product) and the active purchases' fetched-product groups (StoreKit 1's only source, where the snapshot has no subscriptions). The residual fetch dependency only affects StoreKit 1 — pre-existing, and backstopped by SK1's own `isEligibleForIntroOffer`, which already blocks any same-group purchase. Adds unit tests for the computation covering the delisted-product, StoreKit 1, and inactive-subscription cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — re-reviewed the single new commit (152fa05) since the prior pullfrog review (17b2490), which reworks the active-group derivation to close the partial-fetch fail-open and adds the missing direct test coverage.
- Active-group set now unioned from two sources —
computeActiveSubscriptionGroupIdstakes(from: PurchaseSnapshot, storeProducts:)and unions group IDs from activecustomerInfo.subscriptionstransactions (SK2 carriessubscriptionGroupId, so this survives a delisted/unfetchable product) with activepurchasesjoined to fetchedstoreProducts(the SK1 path). This closes the SK2 partial-fetch fail-open flagged on the prior review. - Direct coverage for
computeActiveSubscriptionGroupIds— three new tests drive the function end-to-end:activeGroupFromTransactionWhenProductMissing(SK2 transaction survives missing product),activeGroupFromFetchedProducts(SK1 path), andinactiveSubscriptionsExcluded. Resolves the prior no-coverage finding.
Both prior incremental concerns are addressed. The SK2 fail-open window is now closed via the transaction union; the SK1 path still derives the group from the fetched product, which is inherent (SK1 transactions carry no group ID) — a narrow, informational residual rather than a regression. The carried-forward .eligible override scope question from the initial review remains a design decision for the author and is unrelated to these commits.
Claude Opus | 𝕏
… fetch success `activeSubscriptionGroupIds` was only recomputed after the purchased products fetched successfully, so a failed fetch left it describing the previous load — which could hide a trial from a lapsed user or show one to an active same-group subscriber. It's now refreshed from the snapshot's transactions *before* the fetch guard (StoreKit 2 transactions carry the group, so no fetch is needed), and enriched with product-derived groups when the fetch succeeds. So `isFreeTrialAvailable` always reflects the current load. Reset/identity changes don't need to clear it: the set is derived from the device's StoreKit transactions, which are Apple-ID-scoped and unchanged by a Superwall identity switch — and clearing without an immediate rebuild would briefly fail open instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — re-reviewed the single new commit (6b6cff8) since the prior pullfrog review (152fa05), which hardens when the active-subscription-group state is refreshed so the trial gate never reads a stale snapshot.
- Active-group state refreshed before the product fetch —
loadPurchasedProductsnow computesactiveSubscriptionGroupIdsfrom the current snapshot alone (storeProducts: []) before theproductsManager.products()guard, then recomputes it enriched with fetched products on success. On a fetch failure (theguard ... else { return }), the set reflects this load's StoreKit 2 transaction-derived groups instead of a previous load's, closing the stale-state window flagged by Greptile. Identity-change /reset()staleness is moot because the snapshot is Apple-ID-scoped. - Doc comment condensed — the
isFreeTrialAvailabledocumentation was tightened; prose only, no behavior change.
The fix is sound. The early storeProducts: [] path is already exercised by the existing activeGroupFromTransactionWhenProductMissing test, which drives computeActiveSubscriptionGroupIds against the SK2 transaction source with no fetched products. The residual SK1 fetch-failure case (an active SK1 sub's group drops when its product can't be fetched) is inherent — SK1 transactions carry no group ID — and was confirmed by the author on the prior thread; it is unchanged by this commit. The carried-forward .eligible-override scope question from the initial review remains a design decision for the author and is unrelated to these commits.
Claude Opus | 𝕏
…e fetch await `ReceiptManager` is an actor, so the `await productsManager.products(...)` in loadPurchasedProducts is a suspension point where a re-entrant `isFreeTrialAvailable` can run. The previous code assigned `activeSubscriptionGroupIds` from the snapshot *before* that await; on StoreKit 1 the snapshot has no subscription groups, so the set was momentarily empty and a concurrent paywall refresh could show a trial for an upgrade. `activeSubscriptionGroupIds` is now assigned only *after* the await — once on success (snapshot ∪ product-derived groups) and once in the failure branch (snapshot only). During the suspension the property keeps its previous value rather than a half-built one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — re-reviewed the single new commit (495161c) since the prior pullfrog review (6b6cff8), which fixes a re-entrancy window in how the active-subscription-group state is published during loadPurchasedProducts.
- Active-group set no longer published before the fetch await —
495161creverses the6b6cff8approach: the snapshot-onlycomputeActiveSubscriptionGroupIds(from:, storeProducts: [])assignment that ran before theproductsManager.products()call is removed.activeSubscriptionGroupIdsis now mutated only after the await resumes — on the failure path (snapshot-only fallback) and the success path (enriched with fetched products). BecauseReceiptManageris an actor, a re-entrantisFreeTrialAvailableduring the await suspension could previously observe a half-built set (empty on the StoreKit 1 path → trial shown for an upgrade Apple would charge); it now reads the prior load's complete set instead.
This addresses the resolved Greptile P1 inline ("Keep the refreshed set local until the fetch finishes or fails, then assign it once"). The fix is sound: reading the previous load's fully-populated set during the suspension is strictly better than a half-built one, and fails safe toward suppressing trials — the correct billing direction. The snapshot-only fallback path remains covered by the existing activeGroupFromTransactionWhenProductMissing test. The carried-forward .eligible-override scope question from the initial review and the inherent SK1 fetch-failure residual remain unrelated to this commit.
Claude Opus | 𝕏

Summary
A paywall could advertise a free trial that Apple never granted, then charge the user immediately on purchase.
Root cause: StoreKit's
Product.SubscriptionInfo.isEligibleForIntroOfferonly reflects whether the customer has ever consumed an intro offer in the subscription group — it returnstruefor someone who paid for a product in the group but never took a trial. Apple separately does not apply introductory offers to upgrades, crossgrades, or downgrades: when a customer with an active subscription in a group buys a different product in that same group, it's a product change and no trial is applied — regardless of the eligibility flag.Real-world case: a user subscribed to a lower tier since 2017 (never trialed) bought a higher tier while the lower tier was still active.
isEligibleForIntroOfferwastrue, the paywall showed a trial, but Apple returnedPURCHASEDwith no trial and charged them. A week later, after the original subscription lapsed, they bought fresh and Apple did grant the 30-day trial — confirming the difference is "new subscription vs. product change," not eligibility.Changes
1. Gate trial availability on active subscription state (the fix)
ReceiptManager.isFreeTrialAvailablenow also requires that there is no active subscription in the product's subscription group. The set of active subscription groups is computed inloadPurchasedProductsfrom the active purchases and their fetched products — those carry the group ID for both StoreKit 1 and StoreKit 2.loadPurchasedProductsalways completes before a paywall opens (config is only markedretrievedafter it runs, and presentation blocks on config), so the gate reflects current subscription state regardless of StoreKit version. Once the existing subscription lapses, a fresh purchase is eligible for the trial again.2. Remove the never-invalidated SK2 eligibility cache
SK2ReceiptManagercached intro-offer eligibility in a dict that was never invalidated — it survivedreset(), identity switches, and purchases for the whole app-process lifetime. Eligibility is now queried live from StoreKit on each check (a local on-device read, not a network call). Low-severity latent fix; the gate above covers the common scenario. Easy to split out if you'd prefer to land it separately.Test plan
ReceiptManagerTrialEligibilityTests(6 tests): active sub in same group → no trial; lapsed sub → trial returns; different group / no active subs → unaffected; ineligible → no trial; product with no subscription group → unaffected.SK2ReceiptManagerTests(3 tests): eligibility re-queried each call (not cached);loadIntroOfferEligibilityis a no-op; default live resolver is wired.swiftlintclean on all changed files.Note
One pre-existing test,
CustomProductTests.prepareToPurchase_customProduct_marksFreeTrialAvailableWhenUserHasNoPriorEntitlement, fails only under parallel execution — it mutates the globalSuperwall.shared.customerInfo, which a sibling test races. It passes in isolation and with parallelism disabled, and is not on this PR's code path. Happy to make it order-independent in a follow-up if you'd like CI green under parallelism.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This PR fixes trial messaging for subscription product changes. The main changes are:
Confidence Score: 5/5
This looks safe to merge.
Important Files Changed
Reviews (4): Last reviewed commit: "Address review: don't publish a half-bui..." | Re-trigger Greptile
Context used: