Skip to content

4.14.1#447

Merged
yusuftor merged 63 commits intomasterfrom
develop
Mar 11, 2026
Merged

4.14.1#447
yusuftor merged 63 commits intomasterfrom
develop

Conversation

@yusuftor
Copy link
Collaborator

@yusuftor yusuftor commented Mar 5, 2026

Changes in this pull request

Enhancements

  • Localizes all alerts into 41 languages.
  • Makes sure to refresh free trial eligibility on every paywall open.

Fixes

  • Makes device.isSandbox more reliable.
  • Fixes the web restore alert not showing the "Yes" action button and "Cancel" incorrectly triggering the restore action.
  • Fixes a rare issue where a user's subscription could remain active after a refund, preventing paywalls from being shown.
  • Fixes trial eligibility for Stripe paywalls and tracks freeTrial_start.
  • Fixes an issue where transaction_complete could be missing transaction information when a crossgrade occurred while using a purchase controller.
  • Fixes terminated webviews refreshing in a loop on low RAM devices.

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • 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.
  • I have reviewed the contributing guide

Greptile Summary

This PR (4.14.1) delivers a broad set of fixes and enhancements: localization of all SDK alerts into 41 languages, more reliable sandbox detection via AppTransaction, a refund-deactivation fix through two cooperating changes in EntitlementProcessor and SK2ReceiptManager, Stripe trial eligibility support, a fix for the web-restore alert's action wiring, and a behavioral improvement that refreshes free-trial eligibility on every paywall open rather than only at first fetch.

Key changes:

  • Free trial refresh on every openPaywallRequestManager.updatePaywall is now async and calls refreshFreeTrialAvailability, which re-evaluates both App Store and Stripe trial eligibility against the user's current entitlement state each time a paywall is requested.
  • Stripe trial eligibility — New checkStripeTrialEligibility / hasEverHadEntitlement methods determine whether a Stripe product with trialDays > 0 should show a trial offer, using customerInfo.entitlements to detect prior subscriptions without StoreKit.
  • Refund fixEntitlementProcessor now overrides isActive to false when subscription state is .revoked/.expired, and SK2ReceiptManager propagates this correction to the purchases set, preventing a refunded subscription from remaining "active."
  • Web-restore alert fix — Adding the explicit action: label to presentAlert corrects the trailing-closure binding that caused "Cancel" to trigger the restore action instead of "Yes."
  • FreeTrialStart event for web redemptions — The event and trial notifications are now correctly gated on both trialPeriodDays > 0 and paywallInfo.isFreeTrialAvailable.
  • Price calculation accuracySubscriptionPeriod, SK2StoreProduct, StripeProductType, and StoreProductDiscount all updated to use 365/52 and 365/12 for week/month conversions, and a shared Decimal.roundedPrice() extension replaces the per-type private helpers.

Confidence Score: 4/5

  • Safe to merge — fixes are well-targeted and tested; the only concern is a changed equality contract on StripeProduct that could cause unexpected cache misses if trial configuration changes server-side.
  • The changes are backed by solid new tests (365+ lines in WebEntitlementRedeemerTests, 467 lines in StripeTrialEligibilityTests). The refund-fix logic in EntitlementProcessor + SK2ReceiptManager is consistent and addresses a real data-correctness issue. The web-restore alert bug fix is straightforward. The one point of concern worth watching is that StripeProduct.isEqual/hash now include trialDays, which changes object equality semantics and could cause unexpected cache behavior if the server changes trial configuration without changing the product ID.
  • Sources/SuperwallKit/Models/Product/StripeProduct.swift (changed equality semantics) and Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift (core free-trial refresh logic called on every paywall open).

Important Files Changed

Filename Overview
Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Major refactor: free trial eligibility computation moved out of getVariablesAndFreeTrial into a new refreshFreeTrialAvailability method called on every paywall open (cached or not). Adds Stripe trial eligibility check (checkStripeTrialEligibility) and extracts App Store check into checkAppStoreTrialEligibility. Logic is correct but the product loop could be expressed more clearly.
Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift updatePaywall made async; now calls refreshFreeTrialAvailability on every paywall retrieval (cache hit, active task, or fresh fetch), ensuring trial eligibility always reflects current subscription state.
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift Adds a post-processing pass over purchases that corrects isActive to false when all entitlements for that product are revoked/expired — fixes the refund-doesn't-deactivate-subscription bug. Works in tandem with the EntitlementProcessor fix.
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Adds authoritative resolvedIsActive override: forces entitlement isActive = false when subscription state is .revoked or .expired, preventing a revoked subscription from appearing active due to stale transaction data.
Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift Two fixes: (1) FreeTrialStart event now fired on Stripe web redemptions when isFreeTrialAvailable is true; (2) trial notification scheduling is now correctly gated on both trialPeriodDays > 0 and isFreeTrialAvailable. Also fixes TransactionAbandon passing a non-nil product.
Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift Process-termination retry logic now gated on delegate.isActive: retries up to 3× while the paywall is visible, then calls delegate.webViewDidFail() so the user sees an error. Inactive/deallocated delegate path silently marks didFailToLoad. Count reset moved to loadURL.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift All hard-coded English alert strings replaced with LocalizationLogic.localizedBundle() lookups. Critical fix: web-restore alert now uses explicit action: label, preventing the trailing closure from silently binding to the wrong parameter and causing "Cancel" to trigger the restore action.
Sources/SuperwallKit/Models/Product/StripeProduct.swift Adds trialDays: Int? field with proper encode/decode and Equatable/Hashable support. isEqual/hash now include trialDays, changing equality semantics — two products with the same id but different trialDays are now distinct objects, which may affect cache keying.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift Price-per-period calculations updated to use astronomically accurate averages (365/52 days/week, 365/12 days/month) instead of simplified integers. Decimal.roundedPrice() extension extracted here to eliminate duplication across SK2StoreProduct, StripeProductType, and TestStoreProduct.
Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift New currentEntitlement(for:since:) helper used by PurchasingCoordinator to find transactions near the purchase date. Uses currentEntitlements(for:) (iOS 18.4+) or falls back to the deprecated currentEntitlement(for:). Fallback will produce a deprecation warning on Swift 6.1+ toolchains.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[PaywallRequestManager.getPaywall] --> B{Cached paywall?}
    B -- Yes --> C[updatePaywall async]
    B -- No --> D[getProducts - fetch + build productVariables]
    D --> C
    C --> E[refreshFreeTrialAvailability]
    E --> F{Test mode override?}
    F -- forceAvailable/Unavailable --> G[Return early with override]
    F -- useDefault --> H{Developer isFreeTrial override?}
    H -- Set --> G
    H -- nil --> I[Load storeKitManager.productsById]
    I --> J[Loop App Store products]
    J --> K{Product in productsById?}
    K -- No --> J
    K -- Yes --> L[checkAppStoreTrialEligibility]
    L --> M{eligible?}
    M -- true --> N[isFreeTrialAvailable = true, break]
    M -- false --> J
    N --> O[paywall.isFreeTrialAvailable = true]
    J -- exhausted --> P[paywall.isFreeTrialAvailable = false]
    P --> Q{isFreeTrialAvailable still false?}
    Q -- Yes --> R[checkStripeTrialEligibility]
    R --> S{introOfferEligibility == .ineligible?}
    S -- Yes --> T[return false]
    S -- No --> U[Loop Stripe products with trialDays > 0]
    U --> V[hasEverHadEntitlement via customerInfo]
    V --> W{Has entitlement?}
    W -- No --> X[return true - trial available]
    W -- Yes --> U
    U -- exhausted --> T
    X --> Y[paywall.isFreeTrialAvailable = true]
    Q -- No --> O
    O --> Z[Return updated paywall]
    Y --> Z
    T --> Z
Loading

Comments Outside Diff (1)

  1. Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift, line 76-88 (link)

    unsafePayloadValue used for best-transaction selection

    The loop on iOS 18.4+ uses result.unsafePayloadValue to read the purchase date for comparison purposes before the cryptographic signature has been verified. While the full VerificationResult is ultimately returned (so callers can verify it), the selection of which result to surface can be influenced by a tampered transaction whose purchaseDate has been crafted to appear newer.

    Consider guarding against unverified results when choosing the best transaction:

    for await result in Transaction.currentEntitlements(for: productId) {
      // Skip unverified results when selecting the best candidate
      guard case .verified(let transaction) = result else { continue }
      if !transaction.purchaseDate.isWithinAnHourBefore(purchaseDate) { continue }
      if let currentBest = best?.unsafePayloadValue,
        currentBest.purchaseDate >= transaction.purchaseDate { continue }
      best = result
    }

    This is a pattern already used safely in the fallback path for pre-iOS 18.4, but the new multi-result path skips verification entirely during selection.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift
    Line: 76-88
    
    Comment:
    **`unsafePayloadValue` used for best-transaction selection**
    
    The loop on iOS 18.4+ uses `result.unsafePayloadValue` to read the purchase date for comparison purposes before the cryptographic signature has been verified. While the full `VerificationResult` is ultimately returned (so callers can verify it), the *selection* of which result to surface can be influenced by a tampered transaction whose `purchaseDate` has been crafted to appear newer.
    
    Consider guarding against unverified results when choosing the best transaction:
    
    ```swift
    for await result in Transaction.currentEntitlements(for: productId) {
      // Skip unverified results when selecting the best candidate
      guard case .verified(let transaction) = result else { continue }
      if !transaction.purchaseDate.isWithinAnHourBefore(purchaseDate) { continue }
      if let currentBest = best?.unsafePayloadValue,
        currentBest.purchaseDate >= transaction.purchaseDate { continue }
      best = result
    }
    ```
    
    This is a pattern already used safely in the fallback path for pre-iOS 18.4, but the new multi-result path skips verification entirely during selection.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift, line 2241-2253 ([link](https://github.com/superwall/superwall-ios/blob/68bfaa710552bed189cafbb2668997843047402a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift#L2241-L2253))

    Refund correction only runs when entitlements are non-empty

    The guard !productEntitlements.isEmpty is intentional to avoid falsely flagging products with no mapped entitlements, which is documented. However, a comment explaining this edge case would help future readers understand why a product with zero entitlements is exempt from the correction and keeps its original isActive state even after a refund.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift
    Line: 2241-2253
    
    Comment:
    **Refund correction only runs when entitlements are non-empty**
    
    The guard `!productEntitlements.isEmpty` is intentional to avoid falsely flagging products with no mapped entitlements, which is documented. However, a comment explaining this edge case would help future readers understand why a product with zero entitlements is exempt from the correction and keeps its original `isActive` state even after a refund.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  3. Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift, line 107-122 (link)

    updatePaywall is called on the cached task's value before the task is removed

    When a cached paywall is returned from activeTasks, the sequence is:

    1. existingTask.value is awaited (suspends the actor).
    2. updatePaywall (now async) is called — suspends again.
    3. The task is never removed from activeTasks inside this path.

    Looking at the broader context, the task is removed from activeTasks inside the new-task creation path (where the task itself calls activeTasks[requestHash] = nil). For the existingTask path the task entry stays alive until a subsequent request hits the new-task branch. This is a pre-existing issue but is now more visible because updatePaywall became async and introduces an additional suspension point where a second concurrent caller could reach the existingTask branch and share the same task reference even after the first caller has already consumed the result.

    This is likely fine in practice because updatePaywall doesn't mutate the cached task, but it may be worth adding activeTasks[requestHash] = nil at the end of the existingTask path to keep the lifecycle symmetric with the new-task path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift
    Line: 107-122
    
    Comment:
    **`updatePaywall` is called on the cached task's value before the task is removed**
    
    When a cached paywall is returned from `activeTasks`, the sequence is:
    
    1. `existingTask.value` is awaited (suspends the actor).
    2. `updatePaywall` (now async) is called — suspends again.
    3. The task is never removed from `activeTasks` inside this path.
    
    Looking at the broader context, the task is removed from `activeTasks` inside the new-task creation path (where the task itself calls `activeTasks[requestHash] = nil`). For the `existingTask` path the task entry stays alive until a subsequent request hits the new-task branch. This is a pre-existing issue but is now more visible because `updatePaywall` became async and introduces an additional suspension point where a second concurrent caller could reach the `existingTask` branch and share the same task reference even after the first caller has already consumed the result.
    
    This is likely fine in practice because `updatePaywall` doesn't mutate the cached task, but it may be worth adding `activeTasks[requestHash] = nil` at the end of the `existingTask` path to keep the lifecycle symmetric with the new-task path.
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: c314cb9

Greptile also left 4 inline comments on this PR.

yusuftor and others added 30 commits February 25, 2026 15:01
Previously the SDK used simple divisors for cross-period price
conversions (e.g. month→week divided by 4), which produced different
results than the paywall editor which annualizes first then divides.

For example, $29.99/month weekly price:
- Before: $29.99 ÷ 4 = $7.49
- After:  $29.99 × 12 ÷ 52 = $6.92 (matches editor)

Updated conversion factors across SK1, SK2, Stripe, and Test product
types, plus trial period discount calculations. Added consistent
rounding behavior using NSDecimalNumberHandler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…all/Superwall-iOS into fix_editor_pricing_parity

# Conflicts:
#	Sources/SuperwallKit/StoreKit/Products/StoreProduct/Discount/StoreProductDiscount.swift
#	Sources/SuperwallKit/StoreKit/Products/StoreProduct/SubscriptionPeriod.swift
#	Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift
#	Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift
Align iOS pricing with editor behavior
Use AppTransaction.environment (iOS 16+) as the primary sandbox
detection method, falling back to the receipt URL path check for
older iOS versions. The previous approach only checked
Bundle.main.appStoreReceiptURL for "sandboxReceipt", which could
return false for apps using Stripe or other non-StoreKit payment
methods, including during App Review.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix web restore alert missing action button
When Apple processes a refund, the subscription-level state from
StoreKit's subscriptionStatus correctly returns .revoked. However,
the SDK was not using this authoritative state to override the
per-transaction isActive check. Because Transaction.all returns
the full transaction history and Apple may not set revocationDate
on every transaction under the same originalTransactionId, an
unrevoked transaction with a future expirationDate could keep the
entitlement active even after a refund.

This fix ensures that when the subscription-level state is .revoked
or .expired, isActive is forced to false in both:
1. EntitlementProcessor — so CustomerInfo.entitlements is correct
2. SK2ReceiptManager — so Purchase.isActive is correct, which flows
   into AutomaticPurchaseController.syncSubscriptionStatus and
   ultimately gates paywall presentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Both .revoked and .expired are necessary overrides — the first pass
can incorrectly compute isActive = true from stale transactions in
Transaction.all regardless of the subscription-level state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…lement-active

Fix: revoked subscriptions incorrectly reported as active
For Stripe products, trial eligibility was broken in two modes:
- `.eligible` mode always returned true because subscriptionGroupIdentifier
  is nil for Stripe, so the subscription group check was skipped entirely.
- `.automatic` mode always returned false because the receipt manager tries
  to cast to SK2StoreProduct, which fails for Stripe.

Now uses CustomerInfo entitlements history to determine trial eligibility
for Stripe products. If the user has ever had any entitlement associated
with the product, they are not eligible for a trial.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add trialDays property to StripeProduct decoded from config JSON
- Handle Stripe trial eligibility separately from App Store products
  since Stripe products are never in productsById
- Extract trial eligibility into refreshFreeTrialAvailability so it
  runs on every paywall open, not just the first (fixes stale cache)
- Fix test mode entitlements being filtered out by also considering
  active entitlements in hasEverHadEntitlement
- Rewrite StripeTrialEligibilityTests for the new approach

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yusuftor and others added 8 commits March 10, 2026 13:55
Caps active-paywall reloads at 3 to prevent indefinite loops under
sustained memory pressure where didFinish never fires. The counter
resets on successful load since that breaks the rapid-termination cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only didFinish should reset the counter, since it signals a genuinely
successful load. Resetting in the else branch re-armed the cycle for
active paywalls that hit the cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs fixed:
- didFinish reset defeated the retry cap (terminate -> reload -> success
  -> counter back to 0 -> terminate again, infinitely)
- else branch set didFailToLoad for active paywalls, but viewWillAppear
  never fires while presented, leaving users with a blank screen

Now: no counter reset (lifetime cap per WebView instance), and active
paywalls that exhaust retries are dismissed via webViewDidFail().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reset activeProcessTerminationRetryCount in loadURL(from:) so cached
  SWWebView instances get a fresh retry budget per intentional load.
  This is the correct reset point: didFinish fires after terminate-
  triggered reloads too, but loadURL(from:) only fires for SDK-
  initiated loads.

- Set didFailToLoad = true before calling webViewDidFail() so that
  cached VCs re-presenting trigger a fresh load via viewWillAppear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The counter should cap consecutive unrecovered terminations (rapid kill
loops where didFinish never fires), not total lifetime terminations.
A didFinish after a terminate-triggered reload() means the termination
was recovered — the counter should reset so devices with chronic but
manageable memory pressure aren't penalised.

This is safe because the counter now only gates the active path.
The old bug was a shared counter where didFinish from an unrelated
initial load reset the cap for background paywalls — that path now
bypasses the counter entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…n-retry

Fix WebView process termination retry loop
yusuftor and others added 10 commits March 10, 2026 17:04
…duling

- Fire `FreeTrialStart` event when a Stripe purchase completes and
  `isFreeTrialAvailable` is true, so the delegate receives
  `.freeTrialStart` for Stripe purchases (previously only fired for
  App Store purchases)
- Fix notification scheduling to check `paywallInfo.isFreeTrialAvailable`
  instead of `product.trialPeriodDays > 0`, so notifications respect
  the actual trial eligibility that was sent to Stripe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
isFreeTrialAvailable is paywall-level, so also verify the specific
purchased product has trialPeriodDays > 0 to avoid false positives
when another product on the paywall has a trial.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes mismatch where productIds.first could refer to a different
product than what the user actually purchased on a multi-product paywall.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix existing trial notification test to set isFreeTrialAvailable = true
- Add test: trialPeriodDays > 0 but isFreeTrialAvailable = false → no notification
- Add test: isFreeTrialAvailable = true but trialPeriodDays = 0 → no notification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verifies that the freeTrialStart event is dispatched to the delegate
in the positive case and not dispatched in the three negative cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Track free trial start for Stripe purchases
yusuftor and others added 3 commits March 11, 2026 13:57
Remove the empty `test_isFreeTrialAvailable` stub and extract the
duplicated `roundedPrice` helper from SK2StoreProduct, StripeProductType,
and TestStoreProduct into a shared `Decimal.roundedPrice()` extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…heck

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yusuftor yusuftor merged commit 280fd1a into master Mar 11, 2026
4 checks passed
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