Skip to content

Fix free trial shown for upgrades within an active subscription group#480

Merged
yusuftor merged 7 commits into
developfrom
fix/intro-offer-upgrade-trial-eligibility
Jun 25, 2026
Merged

Fix free trial shown for upgrades within an active subscription group#480
yusuftor merged 7 commits into
developfrom
fix/intro-offer-upgrade-trial-eligibility

Conversation

@yusuftor

@yusuftor yusuftor commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

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.isEligibleForIntroOffer only reflects whether the customer has ever consumed an intro offer in the subscription group — it returns true for 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. isEligibleForIntroOffer was true, the paywall showed a trial, but Apple returned PURCHASED with 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.isFreeTrialAvailable now also requires that there is no active subscription in the product's subscription group. The set of active subscription groups is computed in loadPurchasedProducts from the active purchases and their fetched products — those carry the group ID for both StoreKit 1 and StoreKit 2. loadPurchasedProducts always completes before a paywall opens (config is only marked retrieved after 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
SK2ReceiptManager cached intro-offer eligibility in a dict that was never invalidated — it survived reset(), 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); loadIntroOfferEligibility is a no-op; default live resolver is wired.
  • Full suite: 833 tests pass (run with parallelism disabled). swiftlint clean on all changed files.

Note

One pre-existing test, CustomProductTests.prepareToPurchase_customProduct_marksFreeTrialAvailableWhenUserHasNoPriorEntitlement, fails only under parallel execution — it mutates the global Superwall.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

  • All unit tests pass. (833 pass serially; see note re: the pre-existing parallel-only flake)
  • All UI tests pass. (not run)
  • Demo project builds and runs on iOS. (not run)
  • Demo project builds and runs on Mac Catalyst. (not run)
  • Demo project builds and runs on visionOS. (not run)
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs. (no public API change)
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This PR fixes trial messaging for subscription product changes. The main changes are:

  • Track active subscription groups during receipt loading.
  • Suppress free-trial display for upgrades, crossgrades, and downgrades in the same group.
  • Resolve StoreKit 2 intro-offer eligibility live instead of using a process-lifetime cache.
  • Add tests for active-group gating and live SK2 eligibility reads.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift Adds active subscription-group tracking and uses it to gate free-trial availability for same-group product changes.
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift Removes cached SK2 intro-offer eligibility and routes each check through a live resolver.
Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift Adds tests for trial gating with active, inactive, same-group, and different-group subscription states.
Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift Adds tests that SK2 eligibility is re-read on each call and that preload no longer freezes a value.
SuperwallKit.xcodeproj/project.pbxproj Adds the new receipt-manager test files to the Xcode test target.

Reviews (4): Last reviewed commit: "Address review: don't publish a half-bui..." | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)

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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 subscriptionReceiptManager.isFreeTrialAvailable now additionally requires no active App Store subscription in the product's subscriptionGroupIdentifier, reading the device snapshot from LatestDeviceCustomerInfo.
  • Remove never-invalidated SK2 cacheSK2ReceiptManager drops the process-lifetime sk2IntroOfferEligibility dict; loadIntroOfferEligibility becomes a no-op and eligibility resolves live each call via an injectable resolveIntroOfferEligibility (default liveIntroOfferEligibility).
  • Tests — adds ReceiptManagerTrialEligibilityTests (6) and SK2ReceiptManagerTests (3), wired into the Xcode project; CHANGELOG.md updated.

ℹ️ 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?

Pullfrog  | Fix it ➔View workflow run | Using 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 snapshotisFreeTrialAvailable no longer reads LatestDeviceCustomerInfo.subscriptions (empty on StoreKit 1). Instead loadPurchasedProducts computes activeSubscriptionGroupIds via the new file-scoped computeActiveSubscriptionGroupIds(forActivePurchasesIn:amongProducts:), joining active onDeviceSnapshot.purchases to the fetched storeProducts by product id and collecting subscriptionGroupIdentifier. 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 directlyReceiptManagerTrialEligibilityTests now seeds activeSubscriptionGroupIds through a new init param rather than LatestDeviceCustomerInfo; the store-specific nonAppStoreSubscriptionDoesNotBlock case 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 sourcescomputeActiveSubscriptionGroupIds takes (from: PurchaseSnapshot, storeProducts:) and unions group IDs from active customerInfo.subscriptions transactions (SK2 carries subscriptionGroupId, so this survives a delisted/unfetchable product) with active purchases joined to fetched storeProducts (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), and inactiveSubscriptionsExcluded. 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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@yusuftor

Copy link
Copy Markdown
Collaborator Author

@greptile

yusuftor and others added 3 commits June 25, 2026 15:50
… 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 fetchloadPurchasedProducts now computes activeSubscriptionGroupIds from the current snapshot alone (storeProducts: []) before the productsManager.products() guard, then recomputes it enriched with fetched products on success. On a fetch failure (the guard ... 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 isFreeTrialAvailable documentation 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.

Pullfrog  | View workflow run | Using 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 await495161c reverses the 6b6cff8 approach: the snapshot-only computeActiveSubscriptionGroupIds(from:, storeProducts: []) assignment that ran before the productsManager.products() call is removed. activeSubscriptionGroupIds is now mutated only after the await resumes — on the failure path (snapshot-only fallback) and the success path (enriched with fetched products). Because ReceiptManager is an actor, a re-entrant isFreeTrialAvailable during 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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@yusuftor yusuftor merged commit d123e0d into develop Jun 25, 2026
4 checks passed
@yusuftor yusuftor deleted the fix/intro-offer-upgrade-trial-eligibility branch June 25, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant