Skip to content

[Billing Plans]: Only return intro offers on billing plan when fetching for a billing plan product#6890

Merged
fire-at-will merged 2 commits into
billing-plans-devfrom
fix-intro-offer
Jun 3, 2026
Merged

[Billing Plans]: Only return intro offers on billing plan when fetching for a billing plan product#6890
fire-at-will merged 2 commits into
billing-plans-devfrom
fix-intro-offer

Conversation

@fire-at-will

@fire-at-will fire-at-will commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Description

This PR fixes a bug where we would return intro offers on the main StoreKit product for products represented by a billing plan. This meant that if an upFront variation of a product had an intro offer, but the monthly billing plan did not, StoreProduct.introductoryDiscount would return the intro offer, even though it isn't present on the monthly billing plan.

Testing

  • Manually verified the changes
  • Unable to write effective tests due to the SKTestSession bug with SKConfig files.

Note

Medium Risk
Changes purchase-facing offer and intro-eligibility behavior for billing-plan SK2 products on iOS 26.4+; wrong pricing-term selection would mislead paywalls but scope is narrow and guarded by compiler/OS availability.

Overview
Fixes billing-plan StoreProduct instances incorrectly surfacing introductory and promotional offers from the base StoreKit subscription when those offers exist only on other pricing terms (e.g. up-front vs monthly).

SK2StoreProduct now treats a product as a billing plan when compoundProductIdentifier includes a product plan id, caches the matching pricingTerms at init, and uses that slice for introductoryDiscount, discounts, and intro-eligibility checks instead of the subscription-level offers. Eligibility uses the renamed containsSubscriptionOfferTypeOnBillingPlan helper. Unit tests cover representsBillingPlan for with/without plan id.

Reviewed by Cursor Bugbot for commit 094317b. Bugbot is set up for automated code reviews on this repo. Configure here.

@fire-at-will fire-at-will requested a review from a team as a code owner June 2, 2026 19:55
@fire-at-will fire-at-will added the pr:fix A bug fix label Jun 2, 2026
@fire-at-will fire-at-will changed the title only return intro offers on billing plan when one is used [Billing Plans]: Only return intro offers on billing plan when fetching for a billing plan product Jun 2, 2026
self.compoundProductIdentifier = compoundProductIdentifier
self.installmentsInfo = installmentsInfo
#if compiler(>=6.3.2)
self._pricingTerms = Self.pricingTerms(for: sk2Product, installmentsInfo: installmentsInfo)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted this out to be computed when the product is created so that we don't need to recompute it each time offers are loaded.

@emerge-tools

emerge-tools Bot commented Jun 2, 2026

Copy link
Copy Markdown

⚠️ 3 new unused protocols, 4 builds increased size

Name Version Download Change Install Change Approval
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.2 MB ⬆️ 23.4 kB (0.56%) 12.7 MB ⬆️ 52.3 kB (0.42%) ⏳ Needs approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 18.4 MB ⬆️ 61.1 kB (0.33%) 66.6 MB ⬆️ 231.4 kB (0.35%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.4 MB ⬆️ 31.7 kB (0.5%) 28.1 MB ⬆️ 110.7 kB (0.4%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.3 MB ⬆️ 23.2 kB (0.54%) 11.1 MB ⬆️ 48.5 kB (0.44%) ⏳ Needs approval

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.local-source

⚠️ Found new unused protocol: WinBackEligibilityPricingTermsType
⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 52.3 kB (0.42%)
Total download size change: ⬆️ 23.4 kB (0.56%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 3.5 kB
📝 RevenueCat.WinBackOfferEligibilityCalculator.winbackOffersByID(fo... ⬆️ 2.2 kB
DYLD.Exports ⬆️ 1.7 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 1.6 kB
Swift.Sequence.compactMap ⬇️ -1.3 kB
View Treemap

Image of diff

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚠️ Found new unused protocol: WinBackEligibilityPricingTermsType
⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 231.4 kB (0.35%)
Total download size change: ⬆️ 61.1 kB (0.33%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 84.4 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 7.5 kB
Code Signature ⬆️ 5.5 kB
DYLD.Exports ⬆️ 4.9 kB
RevenueCat.ProductsManager.ProductsManager ⬆️ 4.4 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.cocoapods

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 110.7 kB (0.4%)
Total download size change: ⬆️ 31.7 kB (0.5%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 39.8 kB
Code Signature ⬆️ 2.5 kB
📝 RevenueCat.WinBackOfferEligibilityCalculator.winbackOffersByID(fo... ⬆️ 2.3 kB
DYLD.Exports ⬆️ 1.6 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 1.6 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.spm

⚠️ Found new unused protocol: WinBackEligibilityPricingTermsType
⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 48.5 kB (0.44%)
Total download size change: ⬆️ 23.2 kB (0.54%)

Largest size changes

Item Install Size Change
📝 RevenueCat.WinBackOfferEligibilityCalculator.winbackOffersByID(fo... ⬆️ 2.2 kB
DYLD.Exports ⬆️ 1.7 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 1.6 kB
Swift.Sequence.compactMap ⬇️ -1.3 kB
Strings.Unmapped ⬆️ 1.2 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

func contains(
subscriptionOfferType: StoreKit.Product.SubscriptionOffer.OfferType,
on billingPlanType: BillingPlanType
func containsSubscriptionOfferTypeOnBillingPlan(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we now scope the SK2Product to just one billing plan, the way the method was structured previously doesn't really make sense anymore. Updated it to check for an offer on the pricing terms contained in the product!

@fire-at-will fire-at-will requested a review from ajpallares June 2, 2026 20:43

@rickvdl rickvdl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Couple of questions mainly :)

.first
.flatMap { StoreProductDiscount(sk2Discount: $0, currencyCode: self.currencyCode) }
} else {
return introductoryDiscountOnProduct()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you mentioned we probably can’t unit test the billing-plan support through SKTestSession, but could we at least test the introductoryDiscountOnProduct() branches of this getter?

@fire-at-will fire-at-will Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Luckily, this path is already covered by existing tests:

StoreProductTests.testSk2DetailsWrapsCorrectly() fetches a StoreKit 2 product with an intro offer from a SKConfig file and verifies the intro offer's contents. This exercises the plain introductoryDiscountOnProduct() branch of the getter since it doesn't have a billing plan on it

.subscription?
.pricingTerms
.first(where: { $0.billingPlanType == billingPlan.skBillingPlanType })
if self.compoundProductIdentifier.productPlanIdentifier != nil,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a bit (without having all the context) to understand this line, and it’s used in a couple of places, would it make sense to extract it to a helper like

/// Whether this product represents a specific billing plan rather than the base product.
var representsBillingPlan: Bool {
  self.compoundProductIdentifier.productPlanIdentifier != nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this! Good to not repeat yourself, and it definitely makes things easier to read. Added the computed property + tests and switched over all of those calls in 094317b


#if compiler(>=6.3.2)
if let installmentsInfo = sk2StoreProduct.installmentsInfo,
if sk2StoreProduct.installmentsInfo != nil,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it (theoretically) be that we’re divergenging here, since in other places we’re checking for self.compoundProductIdentifier.productPlanIdentifier != nil? Should we combine these checks just to be safe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice, probably not, but I think it still makes sense to standardize how we're computing this. In 094317b, I switched this to use our new SK2StoreProduct. representsBillingPlan instead :)

@fire-at-will fire-at-will requested a review from rickvdl June 3, 2026 11:38

@rickvdl rickvdl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one question; would it make sense to (even thought they won't work) already write and include the SK tests so that they'll either succeed or gain our attention once the Xcode bug is resolved like Antonio suggested over Slack?

@fire-at-will

Copy link
Copy Markdown
Contributor Author

@rickvdl Yeah, I think we should! I'm going to go ahead and merge this into the dev branch and then will create a new PR going into the dev branch with several tests backed by SKConfig files

@fire-at-will fire-at-will merged commit e39fcb4 into billing-plans-dev Jun 3, 2026
42 of 43 checks passed
@fire-at-will fire-at-will deleted the fix-intro-offer branch June 3, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants