Add Apple iOS 26 billing plan type support#475
Merged
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>
…as annual The MONTHLY billing plan on an annual subscription means "the user pays monthly for 12 months totaling the annual price." Apple exposes two sides: - `billingPrice` / `billingPeriod`: the per-cycle charge ($3.33 / month) - `commitmentInfo.price` / `commitmentInfo.period`: the full annual commitment ($39.96 / year) I had cached the *billing* side at init, which made every period accessor (period, periodly, periodWeeks, etc.) report month and every price accessor report $3.33. That contradicts the paywall designer's intent — they configured an annual product and expect the paywall to read as annual. The dashboard preview shows year/$2.49, the iOS simulator was showing month/$3.33 — confusing. Switch the cache to `commitmentInfo.price` / `commitmentInfo.period`. Now an annual MONTHLY product surfaces as: - period: year - yearlyPrice: $39.96 - monthlyPrice: $3.33 (computed: $39.96 / 12) - weeklyPrice / dailyPrice: derived from $39.96 / year For UP_FRONT, billingPrice == commitmentInfo.price and billingPeriod == commitmentInfo.period, so its behavior doesn't change. Per-cycle data (`$3.33 / month`) is still reachable through Apple's SK2 API for paywalls that want to render "Billed $X/month with 12-month commitment" copy; a future change can expose `billingPrice` and `billingPeriod` as dedicated paywall attributes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs the simulator surfaced once the MONTHLY plan started flowing through: 1. `price` showed $3.33 / `yearlyPrice` showed $3.33 for an annual MONTHLY product whose Commitment Total is $39.96. Apple's `commitmentInfo.price` empirically returns the per-cycle amount, not the total — even though `commitmentInfo.period` correctly returns `year`. Compute the commitment total ourselves as `billingPrice × cycles in commitment`, where cycles is derived by normalizing billing + commitment periods to a common day count. For UP_FRONT (where billingPeriod == commitmentInfo.period), cycles = 1 and the result equals billingPrice — no behavior change. 2. `hasFreeTrial` / `trialPeriodDays` / `trialPeriodPrice` and friends all read from `underlyingSK2Product.subscription?.introductoryOffer`, which is Apple's default-plan field. On iOS 26.4+ each PricingTerm carries its own `subscriptionOffers` array — the MONTHLY plan can have a different intro offer than the UP_FRONT plan. Cache the plan-specific intro offer at init via `term[offers: .introductory].first` and add a `selectedIntroductoryOffer` accessor that falls back to the legacy field. Replace every legacy read with the new accessor. Both changes apply only when a billing plan is configured AND iOS 26.4+ is available; legacy behavior is preserved otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit's `replace_all` swapped every `underlyingSK2Product.subscription?.introductoryOffer` for `selectedIntroductoryOffer` — including inside the accessor's own fallback, which made the property recurse on itself and crash with `EXC_BAD_ACCESS` from stack overflow on any product that hit the legacy path (no billing plan configured). Restore the literal field reference inside the accessor only; external consumers continue to go through `selectedIntroductoryOffer` as intended. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`cachedSelectedIntroductoryOffer` is nil for two distinct reasons: 1. No billing plan was matched (legacy product, older OS, unsupported region). The legacy `subscription?.introductoryOffer` is the right fallback here. 2. A billing plan *was* matched, but that plan's `subscriptionOffers` doesn't include an introductory offer (e.g. UPFRONT plan has a free trial configured in ASC, MONTHLY plan does not). The previous accessor coalesced both with `??`, which meant case (2) silently re-surfaced the default-plan's offer — a MONTHLY purchase would display the UPFRONT plan's trial. Bug shown clearly when swapping the UPFRONT plan's intro from "Free 1 Month" to "Pay-as-you-go $0.99 / 1 Year": the simulator (purchasing MONTHLY) flipped right along with it, even though MONTHLY had no intro offer in ASC. Gate the fallback on `cachedHasMatchedTerm` so the accessor honors a nil cached value when the plan matched. Legacy products still fall through to the underlying SK2 introductoryOffer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the existing repo preference (per memory.md) of using
`if X == nil { return blah }` rather than `guard X != nil else { ... }`
— the negation is harder to read and the positive form is consistent
with how the rest of this file handles nil checks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a Product's configured billing plan isn't honored on the device (older OS, US/Singapore for MONTHLY, etc.), the SDK falls back to Apple's default plan — price/period accessors already reflect that. Surfacing the dashboard's intended billing plan in `StoreProduct.billingPlanType` regardless meant paywall templates could render "Subscribe with 12-month commitment for $X/month" over a purchase that would actually charge $Y upfront. Designers would have had to manually gate every reference to `billingPlanType` on `isBillingPlanAvailable` or risk a price-mismatch surprise. Make `billingPlanType` reflect what will be charged: return the configured value only when `isBillingPlanAvailable` is true, else nil. Single source of truth for the template, and the purchase path inherits the gate too — `ProductPurchaserSK2` already guards `if let plan = product.billingPlanType` before inserting the SK2 option, so the unavailable case now silently uses Apple's default. `isBillingPlanAvailable` keeps its diagnostic role: distinguishes "no plan configured" from "configured but unavailable here." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SK2 billing-plan work depends on `Product.SubscriptionInfo.PricingTerms`, which only exists in the iOS 26.4 SDK and newer. GitHub's `macos-26` runner image ships multiple Xcodes side by side but `xcodebuild` defaults to whichever was selected when the image was provisioned — usually an older 26.x. That's why tests pass locally (where 26.4+ is selected) but CI fails with `'PricingTerms' is not a member type`. Add `maxim-lobanov/setup-xcode@v1` with `latest-stable` to every macos-26 workflow so they all build against the newest Xcode the runner has installed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously `SK2StoreProduct.isBillingPlanAvailable` returned `true`
when no billing plan was configured. That meant a paywall template
gating UI on `isBillingPlanAvailable` (e.g. "render the
12-month-commitment copy when there's a plan to use") would also fire
for legacy products that have no plan at all — exactly the misuse the
attribute is meant to prevent.
Flip the legacy case to `false` so the attribute reads cleanly as "is
there a billing plan to use right now?":
- No plan configured (legacy): `false`
- Plan configured + honored by device (iOS 26.4+, supported region):
`true`
- Plan configured + not honored (older OS, US/Singapore for MONTHLY):
`false` — `cachedHasMatchedTerm` already returns false here.
Paywall designers can now write `{{#if isBillingPlanAvailable }}…{{/if}}`
without separately checking `billingPlanType` — a single source of
truth. The dashboard preview's hard-coded `"true"` is being aligned in
the paywall-next companion commit so editor and runtime agree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fold the TransactionManager product lookup into a single `??` so the Apple-ID fallback only runs when the composite-keyed map misses, saving an actor hop on the common paywall-purchase path. Also correct the BillingPlanType availability doc to iOS 26.4+, widen its StringValue enum to internal, and add SwiftLint length suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion getProducts reset self.productsByCompositeId on every call, but that map is a shared, cross-paywall cache populated only once per paywall (at first request/preload). Preloading several billing-plan paywalls left only the last one's composite entries, so purchasing a billing-plan product from any other preloaded paywall missed both maps and failed with productUnavailable. Accumulate into the map instead — composite IDs map deterministically to stable base data plus a fixed billing plan, so entries can't go stale. Also fixes the actor-isolation build error from resolving the product via a `??` autoclosure: resolution now lives in a single actor-isolated `product(withId:)` method (one hop, fallback only on a composite miss). Migrates StoreKitManagerTests to Swift Testing and adds a regression test asserting two billing-plan paywalls both stay resolvable in the composite map after loading in sequence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The StoreProductType default returned true, so SK1/custom products (which never configure a billing plan) reported isBillingPlanAvailable == true. That contradicted SK2StoreProduct (false when no plan is configured) and the documented StoreProduct contract, and leaked "isBillingPlanAvailable": "true" into the attributes dict — making paywall templates that gate billing-plan copy behave differently in test mode (SK1) than production (SK2). Flip the default to false and correct the protocol doc comment. Adds a regression test asserting an SK1 product reports no billing plan available, nil billingPlanType, and "false" in its attributes. Co-Authored-By: Claude Opus 4.8 (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 type support, allowing two Superwall Products sharing the same Apple product identifier to differ in their
BillingPlanType(.upFront/.monthly) and be merchandised independently on a paywall.AppStoreProductgains an optionalBillingPlanTypedecoded frombillingPlanType;SK2StoreProductresolves the matchingPricingTermsat init and caches price/period/intro-offer from it, gated behind#if compiler(>=6.3)/#available(iOS 26.4, *).StoreKitManagerfetches by Apple ID and builds a per-callproductsByCompositeIdmap of clonedStoreProductinstances, each carrying an independent billing plan;TransactionManagerandAddPaywallProductsresolve lookups through this composite map first.ProductPurchaserSK2inserts.billingPlanType(...)into the SK2 purchase-option set on iOS 26.4+ when a plan is active; CI workflows pin tolatest-stableXcode so the iOS 26.4 SDK is always available.Confidence Score: 5/5
Safe to merge; the new billing plan paths are dormant until the server ships the corresponding field, so existing paywalls are unaffected.
All production code paths are correctly gated behind compiler and availability checks. The composite-ID map accumulates entries without ever overwriting the Apple-ID cache, keeping preloaded paywalls intact. The only inconsistency is a protocol-default value that diverges from the SK2 override and the public documentation — it surfaces only in test mode for SK1 products and has no production impact.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift — the default isBillingPlanAvailable value diverges from SK2 behaviour and the StoreProduct documentation.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Server participant Paywall participant SKManager as StoreKitManager participant SKProduct as SK2StoreProduct participant Purchaser as ProductPurchaserSK2 Server->>Paywall: Decode products with billingPlanType Note over Paywall: appStoreProductIdentifiers (deduped Apple IDs) Paywall->>SKManager: getProducts(forPaywall:) SKManager->>SKManager: fetch by Apple ID (StoreKit.Product.products) loop Each App Store product slot SKManager->>SKProduct: copyForCompositeProduct(billingPlanType:) Note over SKProduct: Resolves PricingTerms at init, caches price/period/intro offer SKManager->>SKManager: "productsByCompositeId[compositeId] = clone" end SKManager-->>Paywall: (productsByCompositeId, productItems) Note over Paywall: User taps purchase Paywall->>SKManager: product(withId: compositeId) SKManager-->>Paywall: StoreProduct clone with billingPlanType Paywall->>Purchaser: purchase(product:) alt iOS 26.4+ and plan available Purchaser->>Purchaser: options.insert(.billingPlanType(sk2Plan)) end Purchaser->>SKProduct: sk2Product.purchase(options:)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 (4): Last reviewed commit: "Stop wiping composite product cache betw..." | Re-trigger Greptile