Conversation
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>
Fix sandbox detection
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>
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
Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift
Show resolved
Hide resolved
Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTests.swift
Outdated
Show resolved
Hide resolved
…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
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift
Outdated
Show resolved
Hide resolved
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>
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift
Show resolved
Hide resolved
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift
Outdated
Show resolved
Hide resolved
Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift
Show resolved
Hide resolved
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes in this pull request
Enhancements
Fixes
device.isSandboxmore reliable.freeTrial_start.transaction_completecould be missing transaction information when a crossgrade occurred while using a purchase controller.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.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 inEntitlementProcessorandSK2ReceiptManager, 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:
PaywallRequestManager.updatePaywallis nowasyncand callsrefreshFreeTrialAvailability, which re-evaluates both App Store and Stripe trial eligibility against the user's current entitlement state each time a paywall is requested.checkStripeTrialEligibility/hasEverHadEntitlementmethods determine whether a Stripe product withtrialDays > 0should show a trial offer, usingcustomerInfo.entitlementsto detect prior subscriptions without StoreKit.EntitlementProcessornow overridesisActivetofalsewhen subscription state is.revoked/.expired, andSK2ReceiptManagerpropagates this correction to thepurchasesset, preventing a refunded subscription from remaining "active."action:label topresentAlertcorrects the trailing-closure binding that caused "Cancel" to trigger the restore action instead of "Yes."FreeTrialStartevent for web redemptions — The event and trial notifications are now correctly gated on bothtrialPeriodDays > 0andpaywallInfo.isFreeTrialAvailable.SubscriptionPeriod,SK2StoreProduct,StripeProductType, andStoreProductDiscountall updated to use 365/52 and 365/12 for week/month conversions, and a sharedDecimal.roundedPrice()extension replaces the per-type private helpers.Confidence Score: 4/5
StripeProductthat could cause unexpected cache misses if trial configuration changes server-side.WebEntitlementRedeemerTests, 467 lines inStripeTrialEligibilityTests). The refund-fix logic inEntitlementProcessor+SK2ReceiptManageris consistent and addresses a real data-correctness issue. The web-restore alert bug fix is straightforward. The one point of concern worth watching is thatStripeProduct.isEqual/hashnow includetrialDays, 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) andSources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift(core free-trial refresh logic called on every paywall open).Important Files Changed
getVariablesAndFreeTrialinto a newrefreshFreeTrialAvailabilitymethod called on every paywall open (cached or not). Adds Stripe trial eligibility check (checkStripeTrialEligibility) and extracts App Store check intocheckAppStoreTrialEligibility. Logic is correct but the product loop could be expressed more clearly.updatePaywallmade async; now callsrefreshFreeTrialAvailabilityon every paywall retrieval (cache hit, active task, or fresh fetch), ensuring trial eligibility always reflects current subscription state.purchasesthat correctsisActivetofalsewhen all entitlements for that product are revoked/expired — fixes the refund-doesn't-deactivate-subscription bug. Works in tandem with theEntitlementProcessorfix.resolvedIsActiveoverride: forces entitlementisActive = falsewhen subscription state is.revokedor.expired, preventing a revoked subscription from appearing active due to stale transaction data.FreeTrialStartevent now fired on Stripe web redemptions whenisFreeTrialAvailableis true; (2) trial notification scheduling is now correctly gated on bothtrialPeriodDays > 0andisFreeTrialAvailable. Also fixesTransactionAbandonpassing a non-nil product.delegate.isActive: retries up to 3× while the paywall is visible, then callsdelegate.webViewDidFail()so the user sees an error. Inactive/deallocated delegate path silently marksdidFailToLoad. Count reset moved toloadURL.LocalizationLogic.localizedBundle()lookups. Critical fix: web-restore alert now uses explicitaction:label, preventing the trailing closure from silently binding to the wrong parameter and causing "Cancel" to trigger the restore action.trialDays: Int?field with proper encode/decode and Equatable/Hashable support.isEqual/hashnow includetrialDays, changing equality semantics — two products with the sameidbut differenttrialDaysare now distinct objects, which may affect cache keying.Decimal.roundedPrice()extension extracted here to eliminate duplication acrossSK2StoreProduct,StripeProductType, andTestStoreProduct.currentEntitlement(for:since:)helper used byPurchasingCoordinatorto find transactions near the purchase date. UsescurrentEntitlements(for:)(iOS 18.4+) or falls back to the deprecatedcurrentEntitlement(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 --> ZComments Outside Diff (1)
Sources/SuperwallKit/Misc/Extensions/Transaction/Transaction+LatestSince.swift, line 76-88 (link)unsafePayloadValueused for best-transaction selectionThe loop on iOS 18.4+ uses
result.unsafePayloadValueto read the purchase date for comparison purposes before the cryptographic signature has been verified. While the fullVerificationResultis ultimately returned (so callers can verify it), the selection of which result to surface can be influenced by a tampered transaction whosepurchaseDatehas been crafted to appear newer.Consider guarding against unverified results when choosing the best transaction:
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
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.isEmptyis 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 originalisActivestate even after a refund.Prompt To Fix With AI
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!
Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift, line 107-122 (link)updatePaywallis called on the cached task's value before the task is removedWhen a cached paywall is returned from
activeTasks, the sequence is:existingTask.valueis awaited (suspends the actor).updatePaywall(now async) is called — suspends again.activeTasksinside this path.Looking at the broader context, the task is removed from
activeTasksinside the new-task creation path (where the task itself callsactiveTasks[requestHash] = nil). For theexistingTaskpath 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 becauseupdatePaywallbecame async and introduces an additional suspension point where a second concurrent caller could reach theexistingTaskbranch and share the same task reference even after the first caller has already consumed the result.This is likely fine in practice because
updatePaywalldoesn't mutate the cached task, but it may be worth addingactiveTasks[requestHash] = nilat the end of theexistingTaskpath to keep the lifecycle symmetric with the new-task path.Prompt To Fix With AI
Last reviewed commit: c314cb9