Skip to content

[Billing Plans] Support fetching & purchasing products with billing plans#6783

Merged
fire-at-will merged 48 commits into
mainfrom
billing-plans-dev
Jun 3, 2026
Merged

[Billing Plans] Support fetching & purchasing products with billing plans#6783
fire-at-will merged 48 commits into
mainfrom
billing-plans-dev

Conversation

@fire-at-will

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

Copy link
Copy Markdown
Contributor

Motivation

Adds iOS support for 12month commitment products in StoreKit 2, including fetching, caching, displaying, purchasing, eligibility checks, and CustomerInfo handling.

Description

This PR introduces SDK support for compound product identifiers in the format:

{productIdentifier}:{productPlanIdentifier}

For example:

com.revenuecat.subscription:monthly

The SDK now resolves these compound identifiers to the base StoreKit product ID for product requests, then reconstructs billing-plan-specific StoreProducts when StoreKit 2 returns eligible pricing terms.

Main changes:

  • Adds public BillingPlanType and InstallmentsInfo APIs.
  • Adds StoreProduct.installmentsInfo for eligible monthly billing plan products.
  • Adds StoreProduct.id internally for caching, equality, offerings lookup, intro eligibility, and purchase flows so products with the same StoreKit ID but different billing plans remain distinct.
  • Applies the selected billing plan during StoreKit 2 purchase by inserting .billingPlanType(...) into purchase options.
  • Filters out billing-plan products on unsupported OS versions or StoreKit 1, with logging.
  • Updates promotional offer, intro offer, and win-back offer eligibility calculations to account for billing plan products.
  • Includes billing plan/product plan identifiers in CustomerInfo and offline entitlement paths.
  • Updates API testers and generated API surface files.
  • Adds PurchaseTester UI for manually fetching and purchasing billing-plan products.

Testing

Added or updated coverage for:

  • Compound product identifier parsing and resolution
  • StoreKit 2 product fetching with billing plans
  • Product caching by compound ID
  • Offerings and product entitlement mapping with product plan identifiers
  • InstallmentsInfoFactory
  • BillingPlanType
  • StoreProduct identity/equality with billing plans
  • Intro offer and win-back offer eligibility on billing-plan pricing terms
  • Offline entitlements and CustomerInfo product plan identifiers
  • Swift and Obj-C API tester coverage

Manually tested the changes using both SKConfig files and the sandbox environment.

Availability Requirements

Products with 12mo commitments are only available when these three requirements are met:

  • The application is built with Xcode 26.5+ (swift compiler >=6.3.2)
  • The end user is running the app on iOS 26.4+
  • The end user is not located in Singapore or the United States.

Other Notes

The code in this PR has already been approved in prior pull requests.


Note

Medium Risk
Touches core product fetch, purchase, offerings, caching, and CustomerInfo paths; behavior is gated by OS/compiler but mis-resolution could hide products or fail purchases for billing-plan SKUs.

Overview
Adds StoreKit 2 billing-plan support for compound product IDs (productId:plan, e.g. com.app.sub:monthly), including public BillingPlanType and InstallmentsInfo, plus StoreProduct.installmentsInfo on iOS 26.4+ when built with Xcode 26.5+.

Fetching & identity: A shared resolver parses compound IDs, dedupes StoreKit requests to the base product ID, and SK2 builds plan-specific StoreProducts from pricing terms (with SK1/OS-version filtering and logging). Caching, offerings, entitlement mapping, and StoreProduct.id now key off compound IDs so multiple plans for one StoreKit product stay distinct.

Purchase & eligibility: SK2 purchases pass .billingPlanType when the product has installments info; intro, promotional, and win-back eligibility use billing-plan pricing terms (with testable adapters for win-back). ProductRequestData uses installment billing price for monthly plans.

CustomerInfo / offline: Subscriptions and entitlements carry productPlanIdentifier; offline SK2 products and mapping align with Android-style product:plan keys.

Also adds API testers, PurchaseTester fetch UI, and broad unit tests.

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

* introduce CompoundProductIdentifierTests

* build CompoundProductIdentifier from single string

* only request SK product identifiers in ProductsManager.products()

* hello world

* old xcode compiler fix

* introduce InstallmentInfos

* lint

* api testers

* Update baseline swiftinterface files (#6768)

* InstallmentsInfoFactory

* unit tests

* CEC API tester fix

* include more availabilities

* allow fetching of both base product + compound product at the same time

* make hashable/equals contracts use same properties

* add log for invalid product identifiers + tests

* lint

* lint

* StoreProduct: revert productIdentifier to be the store product ID + introduce billingPlanIdentifier

* add sk2_unrecognized_billing_plan_identifer log

* update string value

* add product fetching logs

* add additional fields to InstallmentsInfo

* add InstallmentsInfo.billingPlanType internally

* fix compilation

* linting

* Update baseline swiftinterface files (#6773)

* update purchase testers

* BillingPlanType API

* Update baseline swiftinterface files (#6779)

* remove billingPlanIdentifier

* Update baseline swiftinterface files (#6780)

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
@fire-at-will fire-at-will changed the title [Billing Plans] Support fetching & purchasing products with billing plans [WIP] [Billing Plans] Support fetching & purchasing products with billing plans May 13, 2026
fire-at-will and others added 3 commits May 14, 2026 10:20
* introduce CompoundProductIdentifierTests

* build CompoundProductIdentifier from single string

* only request SK product identifiers in ProductsManager.products()

* hello world

* old xcode compiler fix

* introduce InstallmentInfos

* lint

* api testers

* Update baseline swiftinterface files (#6768)

* InstallmentsInfoFactory

* unit tests

* CEC API tester fix

* include more availabilities

* allow fetching of both base product + compound product at the same time

* make hashable/equals contracts use same properties

* add log for invalid product identifiers + tests

* lint

* lint

* StoreProduct: revert productIdentifier to be the store product ID + introduce billingPlanIdentifier

* add sk2_unrecognized_billing_plan_identifer log

* update string value

* add product fetching logs

* add additional fields to InstallmentsInfo

* add InstallmentsInfo.billingPlanType internally

* OSAgnosticBillingPlanType

* fix compilation

* linting

* Update baseline swiftinterface files (#6773)

* purchase billing plans

* update purchase testers

* throw error

* fix test compilation

* BillingPlanType API

* Update baseline swiftinterface files (#6779)

* remove billingPlanIdentifier

* update to use public BillingPlanType instead of OSAgnosticBillingPlanType

* Update project.pbxproj

* add logs

* lint

* more lint

* error log

* dont apply upFront by default on all purchases that dont specify it

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
…ackages (#6784)

* introduce CompoundProductIdentifierTests

* build CompoundProductIdentifier from single string

* only request SK product identifiers in ProductsManager.products()

* hello world

* old xcode compiler fix

* introduce InstallmentInfos

* lint

* api testers

* Update baseline swiftinterface files (#6768)

* InstallmentsInfoFactory

* unit tests

* CEC API tester fix

* include more availabilities

* allow fetching of both base product + compound product at the same time

* make hashable/equals contracts use same properties

* add log for invalid product identifiers + tests

* lint

* lint

* StoreProduct: revert productIdentifier to be the store product ID + introduce billingPlanIdentifier

* add sk2_unrecognized_billing_plan_identifer log

* update string value

* add product fetching logs

* add additional fields to InstallmentsInfo

* add InstallmentsInfo.billingPlanType internally

* fix compilation

* linting

* Update baseline swiftinterface files (#6773)

* update purchase testers

* BillingPlanType API

* Update baseline swiftinterface files (#6779)

* remove billingPlanIdentifier

* Update baseline swiftinterface files (#6780)

* include billing plans in offerings response

* Update StoreProductTests.swift

* lint

* Update baseline swiftinterface files (#6785)

* only run test on iOS 26.4+

* rename StoreProduct.compoundProductIdentifier to id and use it for equals

* lint

* fix hash

* update TestStoreProduct

* Update project.pbxproj

* remove out of date test

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
@RevenueCat-Danger-Bot

RevenueCat-Danger-Bot commented May 14, 2026

Copy link
Copy Markdown
1 Warning
⚠️ This PR increases the size of the repo by more than 100.00 KB (increased by 199.50 KB).

Generated by 🚫 Danger

fire-at-will and others added 6 commits May 15, 2026 11:05
* add BillingPlanType.value

* rename to rawValue/remove from()

* api tester

* silly test fix

* Update baseline swiftinterface files (#6797)

* Update baseline swiftinterface files (#6798)

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
…ling plans (#6799)

* update post receipt values for billing plan purchases

* lint

* fix test
…upported OS versions (#6805)

* dont return billing plan products on unsupported OS versions

* negative log assertion in other test

* lint

* remove productsRequestFactory assert
…hen in SK1 Mode (#6806)

* dont return products for compound product identifiers

* lint

* remove availability check

* fix log assertions on old OS versions

* fix test compilation

* test fix

* log fix

* Update ProductsManagerTests.swift
* update product caching cache key

* add additional tests

* lint
…g plan (#6837)

* check eligibility for monthly billing plan

* lint

* package lookup fix

* not found products calculation fix

* fix tests

* small refactor

* re-introduce sk1 log

* move populateSK2CompoundProductsIfSupported to ProductsFetcherSK2

* refactor to structure things better

* pre-compute storeKitProductIdentifiers
@emerge-tools

emerge-tools Bot commented May 27, 2026

Copy link
Copy Markdown

⚠️ 3 new unused protocols, 4 builds increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 18.5 MB ⬆️ 64.5 kB (0.35%) 66.9 MB ⬆️ 227.3 kB (0.34%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.2 MB ⬆️ 21.5 kB (0.51%) 12.8 MB ⬆️ 52.0 kB (0.41%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.4 MB ⬆️ 33.7 kB (0.53%) 28.3 MB ⬆️ 110.8 kB (0.4%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.4 MB ⬆️ 19.3 kB (0.45%) 11.1 MB ⬆️ 44.0 kB (0.4%) ⏳ Needs approval

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

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

Total install size change: ⬆️ 227.3 kB (0.34%)
Total download size change: ⬆️ 64.5 kB (0.35%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 84.4 kB
📝 RCInstallmentsInfo.Objc Metadata ⬆️ 7.5 kB
Code Signature ⬆️ 6.3 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.local-source

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

Total install size change: ⬆️ 52.0 kB (0.41%)
Total download size change: ⬆️ 21.5 kB (0.51%)

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

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

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

Total install size change: ⬆️ 110.8 kB (0.4%)
Total download size change: ⬆️ 33.7 kB (0.53%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 40.0 kB
Code Signature ⬆️ 2.5 kB
📝 RevenueCat.WinBackOfferEligibilityCalculator.winbackOffersByID(fo... ⬆️ 2.2 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: ⬆️ 44.0 kB (0.4%)
Total download size change: ⬆️ 19.3 kB (0.45%)

Largest size changes

Item Install Size Change
📝 RevenueCat.WinBackOfferEligibilityCalculator.winbackOffersByID(fo... ⬆️ 2.2 kB
DYLD.Exports ⬆️ 1.6 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

fire-at-will and others added 4 commits May 27, 2026 14:53
* add BillingPlanType.value

* rename to rawValue/remove from()

* api tester

* silly test fix

* Update baseline swiftinterface files (#6797)

* Update baseline swiftinterface files (#6798)

* customer info updates for products with billing plans

* lint

* standardize calculations

* Update FetchProductsView.swift

* rename

* lint

* add more tests

* Update ButtonComponentViewModelInteractionTests.swift

* test with decoding two entitlements on product with upfront and monthly

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
…plans (#6846)

* check eligibility for monthly billing plan

* lint

* package lookup fix

* not found products calculation fix

* fix tests

* small refactor

* re-introduce sk1 log

* move populateSK2CompoundProductsIfSupported to ProductsFetcherSK2

* refactor to structure things better

* return promo offers for billing plan

* lint
@fire-at-will fire-at-will changed the title [WIP] [Billing Plans] Support fetching & purchasing products with billing plans [Billing Plans] Support fetching & purchasing products with billing plans May 28, 2026
@fire-at-will fire-at-will marked this pull request as ready for review May 28, 2026 21:25
@fire-at-will fire-at-will requested a review from a team as a code owner May 28, 2026 21:25
Comment thread Sources/OfflineEntitlements/CustomerInfo+OfflineEntitlements.swift
Comment thread Sources/Purchasing/ProductsManager.swift
Comment thread Sources/Purchasing/StoreKitAbstractions/BillingPlanType.swift

@tonidero tonidero left a comment

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.

Did a quick look over and spotted a few small things, but nothing big. I didn't do an in-depth review so I would prefer someone else to approve 🙏

Comment thread Sources/OfflineEntitlements/PurchasedSK2Product.swift
)

default:
return nil

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.

Should we log some error in this case? Feels like it should never be hit?

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.

Actually, seems this is only used for tests? If so, maybe we can move it to the tests source set? That way we would avoid using it accidentally.

@fire-at-will fire-at-will Jun 1, 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.

This is used in CompoundProductIdentifierResolver.resolve(), which is ultimately called by ProductsFetcherSK1 & ProductsFetcherSK2 when products are fetched. It allows us to parse product IDs with colons in them, like when a developer calls Purchases.shared.products(["com.rc.product:monthly"])

I agree though that this shouldn't normally be hit! The only time that this should be hit is when a developer manually requests a product ID with more than one colon in it, like: Purchases.shared.products(["com.rc.product:monthly:extra"]).

I've added a warning log in 63dbe0b

Comment thread Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift Outdated
/**
The product plan identifier that unlocked this entitlement (for a Google Play subscription purchase)
The product plan identifier that unlocked this entitlement (for Google Play subscription purchases
and Apple purchases with non-upFront billing plans)

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.

We will need to update this doc in Android/hybrids.

@objc public let commitmentInstallmentPeriod: SubscriptionPeriod

/// Price charged for each installment billing period.
@objc public let installmentBillingPrice: Decimal

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.

I guess this can be calculated by having the price of the product and the commmitmentInstallmentsCount? (which I see is what we do in the factory). Still it can be useful as a convenience utility 👍

Comment thread Tests/APITesters/AllAPITests/SwiftAPITester/StoreProductAPI.swift
Comment thread Sources/Purchasing/StoreKitAbstractions/BillingPlanType.swift
Comment thread Sources/Purchasing/InstallmentsInfoFactory.swift

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

Amazing job! I think this looks great!

Just added some final suggestions regarding some missing API tests.

Comment thread Tests/APITesters/AllAPITests/ObjcAPITester/RCInstallmentsInfoAPI.m
Comment thread Sources/Purchasing/StoreKitAbstractions/BillingPlanType.swift
…ng for a billing plan product (#6890)

* only return intro offers on billing plan when one is used

* simplify logic with representsBillingPlan

@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 e39fcb4. Configure here.

Comment thread Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift
@fire-at-will fire-at-will merged commit d348b91 into main Jun 3, 2026
42 of 43 checks passed
@fire-at-will fire-at-will deleted the billing-plans-dev branch June 3, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants