Subscription Upgrade & Downgrade

Understanding how each platform handles subscription tier changes is crucial for correctly detecting and displaying upgrade/downgrade states in your app. Both iOS and Android support subscription changes, but they handle them differently.

Overview#

When users change their subscription tier (e.g., from monthly to yearly, or from basic to premium), both platforms support upgrades and downgrades:

  • Upgrades: Moving to a higher-tier or longer-duration subscription
  • Downgrades: Moving to a lower-tier or shorter-duration subscription

Subscription upgrades and downgrades only work within the same subscription group. You cannot upgrade or downgrade between subscriptions in different groups.

Platform-specific setup:

  • iOS (App Store Connect): All tiers (e.g., Basic, Premium, Ultimate) must be in the same Subscription Group
  • Android (Google Play Console): All tiers must be in the same Subscription Product with different Base Plans

In App Store Connect, the row order in your Subscription Group determines the tier hierarchy:

  • Position 1 (Top): Automatically recognized as the highest tier
  • Position 2, 3, ...: Lower tiers in descending order

Example order:

  1. Ultimate (yearly) - Highest tier
  2. Premium (yearly)
  3. Ultimate (monthly)
  4. Premium (monthly)
  5. Basic (yearly)
  6. Basic (monthly) - Lowest tier

StoreKit uses this order to automatically determine whether a subscription change is an upgrade or downgrade.

The key difference is in how and when these changes take effect. Select your platform below to see specific implementation details:

Key Fields to Understand#

This is not a bug — it's intentional StoreKit behavior. According to Apple's official documentation and forums:

  • transaction.productID: The currently active subscription product ID
  • autoRenewPreference: The product ID that will be used on the next renewal

Key insight from Apple Developer Forums:

Upgrade Behavior (Charged Immediately)#

When a user upgrades to a higher-tier subscription (e.g., monthly → yearly, basic → premium):

  1. User is charged immediately for the new subscription tier
  2. A new transaction is created (though Transaction.updates may initially show the old product - this is a known StoreKit issue)
  3. autoRenewPreference immediately reflects the new product
  4. transaction.productID updates within a few minutes

When a user upgrades from monthly to yearly subscription:

  1. Immediately after upgrade:
    • productId: still "monthly" (takes time to update)
    • autoRenewPreference: "yearly"
    • pendingUpgradeProductId: "yearly" ✅
  2. A few minutes later:
    • productId: "yearly" (now updated)
    • autoRenewPreference: "yearly"
    • pendingUpgradeProductId: nil (no pending change)

Use pendingUpgradeProductId to detect when an upgrade is in progress, especially during the brief period when productId hasn't updated yet.

swift
// Detecting upgrades with pendingUpgradeProductId
let subscriptions = try await getActiveSubscriptions()

for sub in subscriptions {
    if let renewalInfo = sub.renewalInfoIOS,
       let pendingUpgrade = renewalInfo.pendingUpgradeProductId,
       pendingUpgrade != sub.productId {
        print("⚠️ UPGRADE IN PROGRESS")
        print("  Current: \(sub.productId)")
        print("  Upgrading to: \(pendingUpgrade)")

        // Show UI: "Upgrade processing..."
    }
}

Downgrade Behavior (Applied at Next Renewal)#

When a user downgrades to a lower-tier subscription (e.g., yearly → monthly, premium → basic):

  1. No new transaction is created
  2. Change is reflected in the renewalInfo object only
  3. autoRenewPreference indicates the new (downgraded) product (Stack Overflow - autoRenewalPreference usage)
  4. transaction.productID remains unchanged until the renewal date
  5. User retains access to current tier until expiration

Check autoRenewPreference against productId to detect scheduled downgrades and inform users when the change will take effect.

swift
// Detecting downgrades
let subscriptions = try await getActiveSubscriptions()

for sub in subscriptions {
    if let renewalInfo = sub.renewalInfoIOS,
       let autoRenewPref = renewalInfo.autoRenewPreference,
       autoRenewPref != sub.productId,
       renewalInfo.willAutoRenew {
        print("⚠️ DOWNGRADE SCHEDULED")
        print("  Current (until \(sub.expirationDateIOS ?? 0)): \(sub.productId)")
        print("  Next: \(autoRenewPref)")

        // Show UI: "Your plan will change to [tier] on [date]"
    }
}

Using pendingUpgradeProductId#

The pendingUpgradeProductId field is specifically designed to detect subscription tier changes. It is automatically calculated by comparing productID and autoRenewPreference.

How it works

The field is automatically calculated by comparing autoRenewPreference and productId:

ts
// Internal logic (for reference - this is done automatically)
const pendingUpgradeProductId =
  (autoRenewPreference !== productId && willAutoRenew)
    ? autoRenewPreference
    : null;

Usage in your app

Simply check if pendingUpgradeProductId exists to detect tier changes in progress:

swift
import OpenIap

let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(nil)

for sub in subscriptions {
    if let pending = sub.renewalInfoIOS?.pendingUpgradeProductId {
        let current = sub.productId

        print("Upgrading from \(current) to \(pending)")

        // Show upgrade-in-progress UI
        showUpgradeInProgressUI(current: current, pending: pending)
    }
}

After an upgrade, don't rely on productId to be immediately updated. There can be a delay of several minutes. Instead:

  1. Use pendingUpgradeProductId to detect ongoing tier changes
  2. Check autoRenewPreference to see what the next renewal will be
  3. Only use productId for the current active subscription
swift
// ✅ Correct approach
let effectiveTier = renewalInfo.pendingUpgradeProductId ?? subscription.productId

// ❌ Wrong - may show outdated tier immediately after upgrade
let currentTier = subscription.productId

Best Practices#

  1. Always check pendingUpgradeProductId when displaying subscription status
  2. Show upgrade-in-progress UI when pendingUpgradeProductId is present
  3. For downgrades, inform users when the change will take effect (at renewal)
  4. Listen to purchase events to update UI when productId finally updates
  5. Test both scenarios in sandbox environment to understand the timing

Here's a complete React component that demonstrates all best practices for handling subscription tier changes:

swift
// Complete example: Subscription status view (SwiftUI)
struct SubscriptionStatusView: View {
    @State private var subscription: ActiveSubscription?

    var body: some View {
        Group {
            if let sub = subscription {
                subscriptionContent(sub)
            } else {
                Text("Loading...")
            }
        }
        .task {
            await loadSubscription()
        }
    }

    @ViewBuilder
    func subscriptionContent(_ sub: ActiveSubscription) -> some View {
        let renewalInfo = sub.renewalInfoIOS
        let pending = renewalInfo?.pendingUpgradeProductId

        if let pending = pending, pending != sub.productId {
            // Upgrade in progress
            VStack {
                Text("⏳ Upgrading to \(pending)...")
                Text("Current: \(sub.productId)")
            }
        } else if let autoRenewPref = renewalInfo?.autoRenewPreference,
                  autoRenewPref != sub.productId,
                  renewalInfo?.willAutoRenew == true {
            // Downgrade scheduled
            VStack {
                Text("Current: \(sub.productId)")
                Text("Will change to \(autoRenewPref) on \(formattedDate(sub.expirationDateIOS))")
            }
        } else {
            // Normal active subscription
            VStack {
                Text("Active: \(sub.productId)")
                Text("Renews: \(formattedDate(renewalInfo?.renewalDate))")
            }
        }
    }

    func loadSubscription() async {
        let subs = try? await OpenIapModule.shared.getActiveSubscriptions(nil)
        subscription = subs?.first
    }
}

Official References#