Skip to content

[Billing Plans]: Check eligibility for intro offers on monthly billing plan #6837

Merged
fire-at-will merged 10 commits into
billing-plans-devfrom
intro-offer-eligibility
May 27, 2026
Merged

[Billing Plans]: Check eligibility for intro offers on monthly billing plan #6837
fire-at-will merged 10 commits into
billing-plans-devfrom
intro-offer-eligibility

Conversation

@fire-at-will

@fire-at-will fire-at-will commented May 22, 2026

Copy link
Copy Markdown
Contributor

Motiviation

Determining intro offer eligibility when using billing plans is a bit nuanced. Before using billing plans, we could simply check if:

  • The product is a subscription
  • The product has an introductory offer on it
  • subscription.isEligibleForIntroOffer

However, developers can specify separate offers for each billing plan on a product. To determine if a user is eligible for an introductory offer on a specific billing plan, we must first check if the billing plan has an intro offer on it, and then check subscription.isEligibleForIntroOffer.

Description

Updates the logic for determining intro offer eligibility by first checking if an intro offer is available on the requested billing plan before checking subscription.isEligibleForIntroOffer.

Testing

  • Manually tested the changes through the purchase tester app.
  • Unfortunately, we can't write integration tests for this yet since we can't fetch products on iOS 26.4+ due to a StoreKit bug 🥺

Follow Up PRs

Similar PRs will need to be created for checking promo offer and winback offer eligibility. Those checks were intentionally left out of this PR to keep its size down.


Note

Medium Risk
Touches product fetch and intro-eligibility paths for billing-plan compound IDs; behavior changes for SK2 plan-specific products but is guarded by OS/compiler availability and covered by new unit tests.

Overview
Refactors billing-plan / compound product ID handling and fixes StoreKit 2 intro-offer eligibility when products use plan-specific IDs (e.g. com.app.sub:monthly).

Compound product resolution is centralized in CompoundProductIdentifierResolver, which parses SDK-facing IDs, logs invalid ones, filters billing-plan requests via StoreKit-specific policy, and maps to base StoreKit product IDs. SK1 and SK2 fetchers now call the resolver (SK1 always rejects billing plans with a warning; SK2 allows them on iOS 26.4+). Billing-plan product expansion (pricing terms, installments, replacing base products when only a compound ID was requested) moves from ProductsManager into ProductsFetcherSK2; ProductsManager delegates fetches without duplicating that logic.

Intro eligibility for SK2 now checks whether the requested billing plan actually has an introductory offer (SK2StoreProduct.contains) before using subscription.isEligibleForIntroOffer, instead of relying only on the subscription-level intro offer. Package-level eligibility lookups use storeProduct.id (compound ID when applicable) so results align with billing-plan products.

Tests for the resolver and shouldRemoveBaseSK2Product are added/relocated accordingly.

Reviewed by Cursor Bugbot for commit fe450ed. 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 May 22, 2026 23:11
@fire-at-will fire-at-will added the pr:feat A new feature label May 22, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a57b5ba. Configure here.

Comment thread Sources/Purchasing/Purchases/Purchases.swift
Comment thread Sources/Purchasing/ProductsManager.swift Outdated
@fire-at-will fire-at-will marked this pull request as draft May 22, 2026 23:42
@fire-at-will fire-at-will marked this pull request as ready for review May 23, 2026 15:46
}

#if compiler(>=6.3.2)
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)

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.

I decided to put this check at the SK2StoreProduct level instead of TrialOrIntroPriceEligibilityChecker so that we can reuse this logic when checking for promo offer/winback offer eligibility in follow-up PRs

func products(withIdentifiers identifiers: Set<String>, completion: @escaping Completion) {
let startTime = self.dateProvider.now()

// It's possible for developers to request compound product identifiers that represent both

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.

We needed to move this logic from products(withIdentifiers:) to sk2Products() because the TrialOrIntroPriceEligibilityChecker fetches products with sk2Products, and thus it wasn't receiving the installmentsInfo on products with billing plans.

@emerge-tools

emerge-tools Bot commented May 23, 2026

Copy link
Copy Markdown

4 builds increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 18.0 MB ⬆️ 34.5 kB (0.19%) 64.8 MB ⬆️ 116.3 kB (0.18%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.1 MB ⬆️ 16.3 kB (0.4%) 12.4 MB ⬆️ 33.6 kB (0.27%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.2 MB ⬆️ 20.8 kB (0.34%) 27.3 MB ⬆️ 66.9 kB (0.25%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.2 MB ⬆️ 16.0 kB (0.38%) 10.8 MB ⬆️ 34.1 kB (0.32%) ⏳ Needs approval

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 116.3 kB (0.18%)
Total download size change: ⬆️ 34.5 kB (0.19%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 36.1 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 8.8 kB
DYLD.Exports ⬆️ 3.8 kB
RevenueCat.ProductsManager.ProductsManager ⬆️ 3.7 kB
Code Signature ⬆️ 3.2 kB
View Treemap

Image of diff

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

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

Total install size change: ⬆️ 33.6 kB (0.27%)
Total download size change: ⬆️ 16.3 kB (0.4%)

Largest size changes

Item Install Size Change
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 2.1 kB
DYLD.Exports ⬆️ 1.8 kB
DYLD.String Table ⬆️ 1.4 kB
📝 RevenueCat.TestStoreProduct.init(localizedTitle,price,currencyCod... ⬆️ 1.2 kB
🗑 RevenueCat.TestStoreProduct.init(localizedTitle,price,currencyCod... ⬇️ -1.2 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: ⬆️ 66.9 kB (0.25%)
Total download size change: ⬆️ 20.8 kB (0.34%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 20.3 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 2.1 kB
DYLD.Exports ⬆️ 1.7 kB
Code Signature ⬆️ 1.7 kB
📝 RevenueCat.TestStoreProduct.init(localizedTitle,price,currencyCod... ⬆️ 1.2 kB
View Treemap

Image of diff

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

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

Total install size change: ⬆️ 34.1 kB (0.32%)
Total download size change: ⬆️ 16.0 kB (0.38%)

Largest size changes

Item Install Size Change
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 2.1 kB
DYLD.Exports ⬆️ 1.8 kB
Code Signature ⬆️ 1.2 kB
📝 RevenueCat.TestStoreProduct.init(localizedTitle,price,currencyCod... ⬆️ 1.2 kB
🗑 RevenueCat.TestStoreProduct.init(localizedTitle,price,currencyCod... ⬇️ -1.2 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

@RevenueCat RevenueCat deleted a comment from emerge-tools Bot May 23, 2026
let products = try await self.productsFetcherSK2.products(identifiers: storeKitIdentifiers)
let productsTakingBillingPlansIntoAccount = self.populateSK2CompoundProductsIfSupported(
requestedIdentifiers: compoundProductIdentifiers, products: products
)

@MarkVillacampa MarkVillacampa May 25, 2026

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.

Is there any reason not to move the populateSK2CompoundProductsIfSupported logic into ProductsFetcherSK2? afaik this is the only place it's called, and would encapsulate the SK2 logic more.

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.

Good idea! Moved this in 871ec28

return compoundIdentifier.productIdentifier
} else {
Logger.warn(
StoreKitStrings.sk1_does_not_support_billing_plans(

@MarkVillacampa MarkVillacampa May 25, 2026

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.

I think we could extract this logic into a separate method that returns the compoundProductIdentifiers and use it in sk1product and sk2products. The only significant difference I see is areProductsWithBillingPlansSupported which can be used for sk1 too unless I'm missing something.

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.

Good idea. In d532177, I created a new CompoundProductIdentifierResolver, which does the heavy lifting of resolving the product IDs for us, and I've moved the logic of calling it into ProductsFetcherSK1/ProductsFetcherSK2. Now, ProductsManager doesn't have any of that logic in it, and routes product request to the SKX fetchers, which encapsulate their own logic.

This feels much cleaner, let me know what you think!

Comment on lines +80 to +84
// swiftlint:disable:next function_body_length
func populateSK2CompoundProductsIfSupported(
requestedIdentifiers: Set<CompoundProductIdentifier>,
products: Set<SK2StoreProduct>
) -> Set<SK2StoreProduct> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit this could be broken up a bit so it's more readable by a human, but definitely a nit

@fire-at-will fire-at-will merged commit 9547937 into billing-plans-dev May 27, 2026
16 of 19 checks passed
@fire-at-will fire-at-will deleted the intro-offer-eligibility branch May 27, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants