[Billing Plans]: Check eligibility for intro offers on monthly billing plan #6837
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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.
| } | ||
|
|
||
| #if compiler(>=6.3.2) | ||
| @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
4 builds increased size
RevenueCat 1.0 (1)
|
| 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 |
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 |
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 |
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 |
🛸 Powered by Emerge Tools
Comment trigger: Size diff threshold of 100.00kB exceeded
| let products = try await self.productsFetcherSK2.products(identifiers: storeKitIdentifiers) | ||
| let productsTakingBillingPlansIntoAccount = self.populateSK2CompoundProductsIfSupported( | ||
| requestedIdentifiers: compoundProductIdentifiers, products: products | ||
| ) |
There was a problem hiding this comment.
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.
| return compoundIdentifier.productIdentifier | ||
| } else { | ||
| Logger.warn( | ||
| StoreKitStrings.sk1_does_not_support_billing_plans( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
| // swiftlint:disable:next function_body_length | ||
| func populateSK2CompoundProductsIfSupported( | ||
| requestedIdentifiers: Set<CompoundProductIdentifier>, | ||
| products: Set<SK2StoreProduct> | ||
| ) -> Set<SK2StoreProduct> { |
There was a problem hiding this comment.
nit this could be broken up a bit so it's more readable by a human, but definitely a nit





Motiviation
Determining intro offer eligibility when using billing plans is a bit nuanced. Before using billing plans, we could simply check if:
subscription.isEligibleForIntroOfferHowever, 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
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 fromProductsManagerintoProductsFetcherSK2;ProductsManagerdelegates fetches without duplicating that logic.Intro eligibility for SK2 now checks whether the requested billing plan actually has an introductory offer (
SK2StoreProduct.contains) before usingsubscription.isEligibleForIntroOffer, instead of relying only on the subscription-level intro offer. Package-level eligibility lookups usestoreProduct.id(compound ID when applicable) so results align with billing-plan products.Tests for the resolver and
shouldRemoveBaseSK2Productare added/relocated accordingly.Reviewed by Cursor Bugbot for commit fe450ed. Bugbot is set up for automated code reviews on this repo. Configure here.