Skip to content

Add Apple iOS 26 billing plan type support#475

Open
yusuftor wants to merge 4 commits into
developfrom
claude/refine-local-plan-SrhyO
Open

Add Apple iOS 26 billing plan type support#475
yusuftor wants to merge 4 commits into
developfrom
claude/refine-local-plan-SrhyO

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented May 21, 2026

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.

  • AppStoreProduct gains an optional BillingPlanType (.upFront / .monthly) decoded from the billingPlanType field on the store product payload. Legacy paywalls without the field decode unchanged.
  • Paywall now exposes both appStoreProductIds (composite Product IDs, used for slot addressing) and a new appStoreProductIdentifiers (deduped Apple IDs, used to drive the StoreKit fetch).
  • StoreKitManager fetches by Apple ID and builds a per-call productsByCompositeId map of StoreProduct clones, each carrying its own billingPlanType. Two clones can share an underlying SK2 product with independent billing plans.
  • StoreProduct exposes billingPlanType and isBillingPlanAvailable, plus a copyForCompositeProduct helper.
  • SK2StoreProduct routes price, period, and computed per-period prices through the matching Product.SubscriptionInfo.PricingTerm when a plan 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.

Server dependency

This PR is forward-compatible — it decodes the new billingPlanType field if present and falls back to nil otherwise. The corresponding server change in paywall-next (emitting billing_plan_type on productsV2 entries and composite sw_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

  • 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

🤖 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.

  • Model layer: AppStoreProduct gains an optional BillingPlanType decoded from billingPlanType (camelCase). Paywall adds appStoreProductIdentifiers (deduped Apple IDs for SK fetches) alongside the existing composite appStoreProductIds.
  • Caching layer: StoreKitManager fetches by Apple ID, builds productsById as before, then creates a productsByCompositeId map of per-slot clones — each carrying its own billing plan. The previous redundant pricingTerms iteration is eliminated by resolving and caching the selected price/period at SK2StoreProduct init time.
  • Purchase layer: ProductPurchaserSK2 injects .billingPlanType(...) into SK2 purchase options on iOS 26.4+ when a plan is configured; TransactionManager resolves 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

Filename Overview
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift Adds billing-plan-aware pricing by caching resolved PricingTerms at init. All price/period accessors route through selectedPrice / selectedSubscriptionPeriod; formattedComputedPrice is consistent because subscriptionPeriod wraps selectedSubscriptionPeriod internally.
Sources/SuperwallKit/StoreKit/StoreKitManager.swift Introduces productsByCompositeId alongside productsById; fetches by Apple ID (deduped), then clones each StoreProduct with the slot's billing plan. Test-mode path now also builds a correct composite map. The actor-level composite cache accumulates entries across paywall loads and is never pruned.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift Adds billingPlanType / isBillingPlanAvailable public accessors and copyForCompositeProduct, which correctly carries over all nonisolated(unsafe) mutable state.
Sources/SuperwallKit/Models/Paywall/Paywall.swift Adds appStoreProductIdentifiers (deduped Apple IDs for StoreKit fetches) and deduplicates productIdsWithIntroOffers using a seen-Set filter.
Sources/SuperwallKit/Models/Product/AppStoreProduct.swift Adds ObjC-compatible BillingPlanType enum with a fileprivate StringValue bridge; custom encode/decode uses decodeIfPresent for backwards compatibility; isEqual and hash updated.
Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Switched to productsByCompositeId for product variable lookup with a correct two-tier fallback (composite → Apple-ID) for custom products.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Purchase resolution tries productsByCompositeId first then falls back to productsById; two sequential actor awaits is consistent with the pre-existing pattern.
Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift Inserts .billingPlanType(...) into SK2 purchase options under correct compiler and availability guards; gracefully no-ops on older runtimes.

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(...)])
Loading

Comments Outside Diff (2)

  1. Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift, line 476-479 (link)

    P1 Non-atomic read-modify-write on nonisolated(unsafe) var product

    The billingPlanType setter performs product = product.withBillingPlanType(newValue) — a read followed by a write with no synchronization. Because product is nonisolated(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 external PurchaseController implementations to call this setter. Consider protecting the property with os_unfair_lock or an NSLock, or making the setter internal (with billingPlanType public read-only) and routing mutation exclusively through copyForCompositeProduct.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift
    Line: 476-479
    
    Comment:
    **Non-atomic read-modify-write on `nonisolated(unsafe) var product`**
    
    The `billingPlanType` setter performs `product = product.withBillingPlanType(newValue)` — a read followed by a write with no synchronization. Because `product` is `nonisolated(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 external `PurchaseController` implementations to call this setter. Consider protecting the property with `os_unfair_lock` or an `NSLock`, or making the setter internal (with `billingPlanType` public read-only) and routing mutation exclusively through `copyForCompositeProduct`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/StoreKit/StoreKitManager.swift, line 612-638 (link)

    P2 Test-mode composite map is keyed by Apple IDs, not composite IDs

    In the test-mode branch, testProductsByCompositeId is built by copying from self.productsById (Apple-ID-keyed), so the returned map and self.productsByCompositeId both use Apple IDs as keys. Downstream in AddPaywallProducts, PaywallLogic.getProductVariables looks up products via productsById: mergedProductsByCompositeId using productItem.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
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/StoreKit/StoreKitManager.swift
    Line: 612-638
    
    Comment:
    **Test-mode composite map is keyed by Apple IDs, not composite IDs**
    
    In the test-mode branch, `testProductsByCompositeId` is built by copying from `self.productsById` (Apple-ID-keyed), so the returned map and `self.productsByCompositeId` both use Apple IDs as keys. Downstream in `AddPaywallProducts`, `PaywallLogic.getProductVariables` looks up products via `productsById: mergedProductsByCompositeId` using `productItem.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.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
Sources/SuperwallKit/StoreKit/StoreKitManager.swift:212-217
The actor-level `productsByCompositeId` cache is never cleared between paywall loads, so composite entries from one paywall persist indefinitely. For `productsById` this is intentional (Apple IDs are stable), but composite IDs encode a billing-plan suffix that is paywall-specific. If a user loads many distinct paywalls in a single session, orphaned entries accumulate without bound. A simple guard is to wipe the composite map at the start of each non-test-mode fetch, mirroring what you already do for `productsById` by overwriting individual entries.

```suggestion
    // 7. Build the composite-ID map. For each App Store Product on the
    //    paywall, clone the underlying StoreProduct and attach the slot's
    //    billing plan so price/period accessors route correctly and the
    //    purchase pipeline can pick the plan up later. Two composite entries
    //    sharing an Apple ID get two independent clones.
    //    Reset the actor-level map so entries from a previous paywall don't
    //    linger if this paywall no longer references those composite IDs.
    self.productsByCompositeId = [:]
    var productsByCompositeId: [String: StoreProduct] = [:]
```

### Issue 2 of 2
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift:319-321
`selectedPrice` and `selectedSubscriptionPeriod` are declared `fileprivate`, which exposes them to any code in `SK2StoreProduct.swift` — including future extensions added to the file. Since they exist purely to feed the public protocol surface (`price`, `subscriptionPeriod`, `localizedPrice`, `formattedComputedPrice`), making them `private` would be safer and make the intent clearer.

Reviews (2): Last reviewed commit: "Address billing plan review feedback" | Re-trigger Greptile

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
yusuftor and others added 3 commits May 21, 2026 16:20
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>
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.

2 participants