Skip to content

[Billing Plans] Support fetching products with billing plans#6758

Merged
fire-at-will merged 33 commits into
billing-plans-devfrom
fetching-products-with-billing-plans
May 13, 2026
Merged

[Billing Plans] Support fetching products with billing plans#6758
fire-at-will merged 33 commits into
billing-plans-devfrom
fetching-products-with-billing-plans

Conversation

@fire-at-will

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

Copy link
Copy Markdown
Contributor

Motivation

Populates a new InstallmentInfos object for iOS products with commitments.

Description

Here's what's new:

New APIs

InstallmentsInfo: New class representing the installments that a subscriber commits to when they subscribe to a subscription.

@objc(RCInstallmentsInfo) public final class InstallmentsInfo: NSObject, Sendable {

    /// Number of installments the customer commits to paying.
    @objc public let commitmentInstallmentsCount: Int

    /// Total duration of the customer's installment commitment.
    @objc public let commitmentTotalPeriod: SubscriptionPeriod

    /// Total price the customer commits to paying across all installments.
    @objc public let commitmentTotalPrice: Decimal

    /// Localized display price for ``commitmentTotalPrice``.
    @objc public let commitmentTotalDisplayPrice: String

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

    /// Localized display price for ``installmentBillingPrice``.
    @objc public let installmentBillingDisplayPrice: String
}

InstallmentsInfo is then exposed on StoreProduct. It is populated when a product has installments on it, and is nil otherwise.

@objc(RCStoreProduct) public final class StoreProduct: NSObject, StoreProductType {
    @available(iOS 26.4, tvOS 26.4, watchOS 26.4, macOS 26.4, visionOS 26.4, *)
    @objc public var installmentsInfo: InstallmentsInfo?
}

Product Fetching Changes

When fetching products, you can now provide what I'm calling "compound" product identifiers: product identifiers where there's a colon and a string after the base product ID, like how products are represented in BC5, like com.revenuecat.sub:monthly. The portion after the colon represents a product's billing plan. You can fetch them with await Purchases.shared.products([productID]). Here's what can happen when you request the products:

  • com.revenuecat.sub: Returns the product with the default billing plan. Today, that always defaults to upFront, so you'll receive one product back with no InstallmentsInfo.
  • com.revenuecat.sub:upFront: Returns the product with the upFront billing plan. Since the upFront billing plan has no installments, you'll receive one product back with no InstallmentsInfo.
  • com.revenuecat.sub:monthly: Returns the product with the monthly billing plan. Since the monthly billing plan has installments, you'll receive one product back with InstallmentsInfo populated.
    Note that the billing plan lookup is case sensitive, and if the billing plan isn't found, or the user isn't eligible for it, no products will be returned.

Purchase Tester

Added a new screen to the purchase tester to test the Purchases.shared.products([productID]) function directly.

Testing

We have not yet tested fetching of products with compound product identifiers through the Offerings system. That will come later this week :)


Note

Medium Risk
Changes product fetching and SK2 product identity/equality to support billing-plan-specific variants and new InstallmentsInfo, which could affect product caching/selection and StoreKit 2 results across platforms.

Overview
Adds support for fetching billing-plan-specific StoreKit 2 products using compound product identifiers (e.g. productId:monthly), while transparently requesting only base StoreKit product IDs and warning when invalid identifiers are provided.

Introduces new public APIs BillingPlanType and InstallmentsInfo (exposed via StoreProduct.installmentsInfo on iOS/tvOS/watchOS/macOS/visionOS 26.4+), plus an InstallmentsInfoFactory to derive installment commitment details from StoreKit 2 pricing terms.

Updates SK2StoreProduct to carry a CompoundProductIdentifier, adjusts hashing/equality accordingly, adds extensive unit tests and API tester coverage, and adds a PurchaseTester UI screen to manually fetch/display products and installment info.

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

@fire-at-will fire-at-will changed the title introduce CompoundProductIdentifierTests [Billing Plans] Support fetching products with billing plans May 8, 2026
@fire-at-will fire-at-will marked this pull request as ready for review May 11, 2026 20:09
@fire-at-will fire-at-will requested a review from a team as a code owner May 11, 2026 20:09
Comment thread Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift
Comment thread Sources/Purchasing/ProductsManager.swift
Comment thread Sources/Purchasing/ProductsManager.swift
Comment thread Sources/Purchasing/ProductsManager.swift
Comment thread api/revenuecat-api-ios-simulator.swiftinterface
Comment thread api/revenuecat-api-ios.swiftinterface
Comment thread Sources/Purchasing/StoreKitAbstractions/SK2StoreProduct.swift
Comment thread Sources/Purchasing/ProductsManager.swift

let installmentsInfo: InstallmentsInfo?

let billingPlanIdentifier: String?

@MarkVillacampa MarkVillacampa May 13, 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.

Should we make our own typed billing plan instead of exposing a string? 🤔

EDIT: I see now we're exposing the string version here and we have an internal billingPlanType in InstallmentsInfo. Maybe we could remove this one and find a way to expose the typed one inside InstallmentsInfo? reason: both should be null or non-null at the same time, so conflating all the instalments fields inside InstallmentsInfo seems cleaner?

Comment thread Sources/Purchasing/ProductsManager.swift
@fire-at-will

Copy link
Copy Markdown
Contributor Author

@tonidero @MarkVillacampa Okay, we've gone through another iteration here, and I think we're getting pretty close. I've added a new non-enum type to InstallmentsInfo: our own BillingPlanType:

@objc(RCBillingPlanType)
public final class BillingPlanType: NSObject, Sendable {
    @objc(RCUpFront) public static let upFront = BillingPlanType()
    @objc(RCMonthly) public static let monthly = BillingPlanType()

    private override init() {}
}

I used a NSObject class here since we're trying to avoid new enums, and structs with static let values aren't accessible in Obj-C.

To make sure that we don't somehow have inconsistent state where a value is present on StoreProduct.installmentsInfo.billingPlanType and not on StoreProduct.billingPlanIdentifier, I've removed StoreProduct.billingPlanIdentifier at Mark's suggestion here.

Unless y'all see any issues that should be addressed in this PR, I propose that we merge this into the dev branch and immediately follow up with two PRs since this one is getting pretty large:

  1. Remove :upFront as a valid product identifier request
  2. Add an internal StoreProduct.compoundProductIdentifier computed property that will allow us to resolve product cache key conflicts and properly log the full compound product identifier when you're trying to purchase a product with a billing plan.

Let me know what y'all think!

Comment thread Sources/Purchasing/StoreKitAbstractions/SK1StoreProduct.swift Outdated
@tonidero

Copy link
Copy Markdown
Contributor

One thing to consider, is how we're going to map this new API in the hybrids... If we want to reuse the subscription options, I think in PHC + KMP, we would need to bring back some of the logic we previously had to map to those subscription options, and keep the same style we have in android (I think in hybrid, ideally it behaves the same way).

Having said that, I do think this API makes more sense for iOS, so it's probably better to do this here, and do the required mappings in the PHC + KMP

@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 1 potential issue.

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 7626aef. Configure here.

Comment thread Sources/Purchasing/StoreKitAbstractions/BillingPlanType.swift
@fire-at-will

Copy link
Copy Markdown
Contributor Author

@tonidero I think that the plan was to not adopt the SubscriptionOption API in the hybrids, and accept that the APIs for purchasing a product with installments would be different across iOS/Android.

For the hybrids, I think that they would distinguish the different between a "regular" product and a product with billing plans at purchase time would be the presence of an InstallmentsInfo object

@fire-at-will fire-at-will merged commit fa06a33 into billing-plans-dev May 13, 2026
39 of 41 checks passed
@fire-at-will fire-at-will deleted the fetching-products-with-billing-plans branch May 13, 2026 20:37
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.

4 participants