Skip to content

Add Apple iOS 26 billing plan type support#475

Merged
yusuftor merged 16 commits into
developfrom
claude/refine-local-plan-SrhyO
Jun 1, 2026
Merged

Add Apple iOS 26 billing plan type support#475
yusuftor merged 16 commits into
developfrom
claude/refine-local-plan-SrhyO

Conversation

@yusuftor

@yusuftor yusuftor commented May 21, 2026

Copy link
Copy Markdown
Collaborator

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

  • AppStoreProduct gains an optional BillingPlanType decoded from billingPlanType; SK2StoreProduct resolves the matching PricingTerms at init and caches price/period/intro-offer from it, gated behind #if compiler(>=6.3) / #available(iOS 26.4, *).
  • StoreKitManager fetches by Apple ID and builds a per-call productsByCompositeId map of cloned StoreProduct instances, each carrying an independent billing plan; TransactionManager and AddPaywallProducts resolve lookups through this composite map first.
  • ProductPurchaserSK2 inserts .billingPlanType(...) into the SK2 purchase-option set on iOS 26.4+ when a plan is active; CI workflows pin to latest-stable Xcode 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

Filename Overview
Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift Core billing plan logic: resolves and caches pricing term at init, routes price/period/intro-offer through it; isBillingPlanAvailable correctly returns false for unconfigured plans.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift Adds billingPlanType/isBillingPlanAvailable public surface and copyForCompositeProduct helper; protocol default for isBillingPlanAvailable is true for non-SK2 types, inconsistent with SK2 and public docs.
Sources/SuperwallKit/StoreKit/StoreKitManager.swift Refactored to fetch by Apple ID and build a per-call composite-ID map of cloned StoreProducts; accumulates rather than resets to support preloading across paywalls.
Sources/SuperwallKit/Models/Product/AppStoreProduct.swift Adds optional BillingPlanType enum with manual Codable round-trip; decodeIfPresent ensures backward compatibility with legacy paywall JSON.
Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Lookup path updated to prefer composite-ID map, with Apple-ID fallback for non-composite products; intro-offer check correctly uses Apple-ID-keyed map.
Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift Inserts .billingPlanType(_:) purchase option on iOS 26.4+ when plan is configured; correctly guarded by compiler and availability checks.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift Protocol gains billingPlanType, isBillingPlanAvailable, and withBillingPlanType; default isBillingPlanAvailable returns true for non-SK2 types which diverges from SK2 and StoreProduct documentation.
Sources/SuperwallKit/Models/Paywall/Paywall.swift Adds appStoreProductIdentifiers (deduped Apple IDs for SK fetch) and deduplicates productIdsWithIntroOffers to handle shared Apple IDs across billing plan slots.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Purchase resolution now routes through product(withId:) which prefers composite map before falling back to Apple-ID map.
Tests/SuperwallKitTests/Models/PaywallBillingPlanTests.swift New tests covering appStoreProductIdentifiers deduplication and productIdsWithIntroOffers deduplication; well structured.
Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift Migrated from XCTest to Swift Testing framework; existing product-substitution tests preserved with equivalent assertions.

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:)
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 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift:158-166
The default `isBillingPlanAvailable` returns `true` for all non-SK2 types (SK1, `APIStoreProduct`, custom), but `SK2StoreProduct` correctly returns `false` when no billing plan is configured. Because `StoreProduct.isBillingPlanAvailable` delegates directly to the underlying product, any SK1 test product will expose `"isBillingPlanAvailable": "true"` in its `attributes` dictionary, causing paywall templates that gate billing-plan copy on that variable to behave differently in test mode versus production (where SK2 returns `false` for unconfigured plans). The `StoreProduct` documentation also explicitly promises `false` for legacy products with no plan configured. Flipping the default to `false` aligns behaviour and matches the documented contract.

```suggestion
extension StoreProductType {
  var billingPlanType: AppStoreProduct.BillingPlanType? { nil }
  var isBillingPlanAvailable: Bool { false }
  func withBillingPlanType(
    _ billingPlanType: AppStoreProduct.BillingPlanType?
  ) -> any StoreProductType {
    self
  }
}
```

Reviews (4): Last reviewed commit: "Stop wiping composite product cache betw..." | 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 15 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>
…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>
@yusuftor yusuftor merged commit bcbfa1f into develop Jun 1, 2026
3 checks passed
@yusuftor yusuftor deleted the claude/refine-local-plan-SrhyO branch June 1, 2026 13:27
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