Add Apple iOS 26 billing plan type support#475
Open
yusuftor wants to merge 4 commits into
Open
Conversation
Two Superwall Products that share an Apple product identifier — one configured for the up-front annual plan, one for the monthly-commitment plan — can now be merchandised together on a paywall. Pricing reads from StoreKit's pricingTerms and the selected plan rides into the purchase via the SK2 .billingPlanType(...) option on iOS 26+. Key changes: - AppStoreProduct gains a public, optional BillingPlanType decoded from the storeProduct payload's billingPlanType field. - Paywall keeps appStoreProductIds as composite Product IDs and adds a new appStoreProductIdentifiers list of deduped Apple IDs used to drive the StoreKit fetch. - StoreKitManager fetches by Apple ID, then builds a per-call productsByCompositeId map containing StoreProduct clones with their billing plan attached. Two clones can share an underlying SK2 product with independent billingPlanTypes. - StoreProduct exposes billingPlanType and isBillingPlanAvailable, plus a copyForCompositeProduct helper used by the manager. - SK2StoreProduct routes price, period, and computed per-period prices through the matching Product.SubscriptionInfo.PricingTerm when one is configured, falling back to the existing single-price path otherwise. - TransactionManager resolves purchases via the composite map first, falling back to the Apple-ID map. - ProductPurchaserSK2 inserts .billingPlanType(...) into the SK2 purchase option set on iOS 26+ when a plan is configured. Behaviour change: PaywallInfo.productIds now contains composite IDs (e.g. com.app.annual:MONTHLY) for Products that opt into a billing plan. The underlying Apple identifier is still reachable via the Product's type.appStore.id. https://claude.ai/code/session_01WoCsAoTAqQNKFbfsPYtQ8z
The PricingTerms type is plural in Apple's SDK (not PricingTerm), the BillingPlanType enum lives on Product.SubscriptionInfo (not nested under the term type), term fields are billingPrice / billingPeriod, and the whole API gates on iOS 26.4 rather than 26.0. Also separated the actor hops in TransactionManager so ?? doesn't try to put an actor-isolated read on the rhs autoclosure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Make `StoreProduct.billingPlanType` read-only and `product` a `let` so mutation can only happen via `copyForCompositeProduct`, eliminating the data race on the previously `nonisolated(unsafe) var`. - In test mode, key `productsByCompositeId` by composite ID and clone the cached `StoreProduct` with the slot's billing plan so billing-plan paywalls work in test mode too. - Cache `selectedPrice` / `selectedSubscriptionPeriod` and the matched term flag on `SK2StoreProduct` at init so accessors don't re-scan `pricingTerms` on every property read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reset `productsByCompositeId` at the start of each non-test-mode fetch so orphaned billing-plan composite entries from prior paywalls don't accumulate across a session. - Demote `selectedPrice` / `selectedSubscriptionPeriod` from `fileprivate` to `private` since their only callers are inside the type itself. Co-Authored-By: Claude Opus 4.7 (1M context) <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
Adds support for Apple's iOS 26 billing plan types on annual subscriptions. Two Superwall Products that share an Apple product identifier — one configured for the up-front annual plan, one for the monthly-commitment plan — can now be merchandised together on a paywall.
AppStoreProductgains an optionalBillingPlanType(.upFront/.monthly) decoded from thebillingPlanTypefield on the store product payload. Legacy paywalls without the field decode unchanged.Paywallnow exposes bothappStoreProductIds(composite Product IDs, used for slot addressing) and a newappStoreProductIdentifiers(deduped Apple IDs, used to drive the StoreKit fetch).StoreKitManagerfetches by Apple ID and builds a per-callproductsByCompositeIdmap ofStoreProductclones, each carrying its ownbillingPlanType. Two clones can share an underlying SK2 product with independent billing plans.StoreProductexposesbillingPlanTypeandisBillingPlanAvailable, plus acopyForCompositeProducthelper.SK2StoreProductroutes price, period, and computed per-period prices through the matchingProduct.SubscriptionInfo.PricingTermwhen a plan is configured, falling back to the existing single-price path otherwise.TransactionManagerresolves purchases via the composite map first, falling back to the Apple-ID map.ProductPurchaserSK2inserts.billingPlanType(...)into the SK2 purchase option set on iOS 26+ when a plan is configured.Behaviour change
PaywallInfo.productIdsnow contains composite IDs (e.g.com.app.annual:MONTHLY) for Products that opt into a billing plan. The underlying Apple identifier is still reachable via the Product'stype.appStore.id.Server dependency
This PR is forward-compatible — it decodes the new
billingPlanTypefield if present and falls back tonilotherwise. The corresponding server change inpaywall-next(emittingbilling_plan_typeonproductsV2entries and compositesw_composite_product_ids) is not yet shipped. Until the server ships that, the new code paths stay dormant and existing paywalls behave exactly as before.Checklist
🤖 Generated with Claude Code
Greptile Summary
This PR adds iOS 26 billing plan support, allowing two Superwall Products to share an Apple product identifier while merchandising distinct billing plans (upfront annual vs. monthly commitment) on the same paywall. The implementation is forward-compatible: when the server hasn't yet shipped
billingPlanType, all new code paths stay dormant and existing paywalls are unaffected.AppStoreProductgains an optionalBillingPlanTypedecoded frombillingPlanType(camelCase).PaywalladdsappStoreProductIdentifiers(deduped Apple IDs for SK fetches) alongside the existing compositeappStoreProductIds.StoreKitManagerfetches by Apple ID, buildsproductsByIdas before, then creates aproductsByCompositeIdmap of per-slot clones — each carrying its own billing plan. The previous redundantpricingTermsiteration is eliminated by resolving and caching the selected price/period atSK2StoreProductinit time.ProductPurchaserSK2injects.billingPlanType(...)into SK2 purchase options on iOS 26.4+ when a plan is configured;TransactionManagerresolves purchases via the composite map first.Confidence Score: 5/5
The change is safe to merge; all new code paths remain dormant until the server ships the corresponding billing-plan fields, and legacy paywalls decode unchanged.
The composite-ID strategy is implemented consistently across the model, caching, product variable, and purchase layers. The previous concerns about redundant pricingTerms iteration and test-mode composite map keying are both addressed in this revision. No logic paths produce wrong prices, wrong periods, or failed purchases under the existing server payload.
Sources/SuperwallKit/StoreKit/StoreKitManager.swift — the productsByCompositeId actor cache is never reset between paywall loads.
Important Files Changed
Sequence Diagram
sequenceDiagram participant S as Server (productsV2) participant PW as Paywall model participant SKM as StoreKitManager (actor) participant SK2 as StoreKit.Product participant AP as AddPaywallProducts participant TM as TransactionManager participant PP as ProductPurchaserSK2 S->>PW: "AppStoreProduct {id, billingPlanType}" note over PW: appStoreProductIdentifiers (deduped Apple IDs) note over PW: appStoreProductIds (composite IDs) AP->>SKM: getProductsWithIds(paywall) SKM->>SK2: Product.products(for: appleIDs) SK2-->>SKM: [SK2Product] note over SKM: productsById[appleId] = StoreProduct loop each paywall Product slot SKM->>SKM: copyForCompositeProduct(billingPlanType) note over SKM: productsByCompositeId[compositeId] = clone end SKM-->>AP: (productsByCompositeId, productItems) AP->>AP: merge custom products from productsById AP->>AP: getProductVariables(productsById: merged) note over TM: JS bridge sends Product.id (composite) TM->>SKM: productsByCompositeId[compositeId] alt composite found SKM-->>TM: StoreProduct clone w/ billingPlanType else fallback SKM-->>TM: productsById[compositeId] end TM->>PP: purchase(product) PP->>SK2: product.purchase(options: [.billingPlanType(...)])Comments Outside Diff (2)
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift, line 476-479 (link)nonisolated(unsafe) var productThe
billingPlanTypesetter performsproduct = product.withBillingPlanType(newValue)— a read followed by a write with no synchronization. Becauseproductisnonisolated(unsafe), Swift's concurrency checker treats it as safe, but two concurrent writes (or a write racing a price-property read on the UI thread) produce undefined behavior. This is a real exposure because the docs explicitly invite externalPurchaseControllerimplementations to call this setter. Consider protecting the property withos_unfair_lockor anNSLock, or making the setter internal (withbillingPlanTypepublic read-only) and routing mutation exclusively throughcopyForCompositeProduct.Prompt To Fix With AI
Sources/SuperwallKit/StoreKit/StoreKitManager.swift, line 612-638 (link)In the test-mode branch,
testProductsByCompositeIdis built by copying fromself.productsById(Apple-ID-keyed), so the returned map andself.productsByCompositeIdboth use Apple IDs as keys. Downstream inAddPaywallProducts,PaywallLogic.getProductVariableslooks up products viaproductsById: mergedProductsByCompositeIdusingproductItem.id(the composite ID). For any test fixture that introduces a billing-plan product (e.g.,com.app.annual:MONTHLY), the lookup would silently miss and that product's variables would not be set. This means billing-plan scenarios can't be exercised in test mode until this path is updated too.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (2): Last reviewed commit: "Address billing plan review feedback" | Re-trigger Greptile