Implementing Subscription Downgrade/Upgrade in Mobile Applications
Changing subscription tiers is one of the most confusing parts of StoreKit. Apple and Google handle transitions differently, and "just buy another product" is not an upgrade. Without proper implementation, users end up with two active subscriptions or the transition doesn't happen until the end of the current period without any notification.
How It Works on iOS
In the App Store, all subscriptions within one Subscription Group are automatically managed by Apple: you can't buy two products from the same group simultaneously. When purchasing a new product from the same group, Apple applies one of three policies:
- Immediate upgrade (moving to higher tier): new subscription activates immediately, user is credited with a prorated portion of the remaining period
- Crossgrade at renewal (moving to equivalent tier): new subscription starts at the next renewal date
- Downgrade at renewal (moving to lower tier): current subscription continues until end of period, then new subscription activates
Tier level is set in App Store Connect → Subscription Group → drag-and-drop product order. Top = highest.
Client Implementation (StoreKit 2)
To transition between tiers, call product.purchase() like a regular purchase — StoreKit determines the transition type:
func changePlan(to newProduct: Product) async throws {
let result = try await newProduct.purchase(options: [
.appAccountToken(userAccountToken) // bind to user account
])
switch result {
case .success(let verification):
if case .verified(let transaction) = verification {
// Determine transition type
if let upgradeInfo = transaction.subscriptionGroupID {
await handlePlanChange(transaction: transaction)
}
await transaction.finish()
}
case .pending:
// Transition scheduled for next period
showPendingChangeNotification()
case .userCancelled:
break
}
}
After successful new tier purchase, check current active entitlement:
func getCurrentActivePlan() async -> String? {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.productType == .autoRenewableSubscription {
return transaction.productID
}
}
return nil
}
Transition UI/UX
The main problem is user confusion about when changes take effect. You must explicitly explain:
func planChangeDescription(from current: Product, to new: Product) -> String {
let currentLevel = subscriptionLevel(for: current.id)
let newLevel = subscriptionLevel(for: new.id)
if newLevel > currentLevel {
return "Upgrade to \(new.displayName) will activate immediately. Remaining period credit will be applied."
} else if newLevel == currentLevel {
return "Upgrade to \(new.displayName) will take effect at next renewal."
} else {
return "Current plan \(current.displayName) remains active until \(currentExpirationDate). Then \(new.displayName) begins."
}
}
A confirmation modal with explicit terms is mandatory.
Google Play Billing: Proration Modes
On Android, subscription changes require explicit ProrationMode in BillingFlowParams:
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(newProductDetails)
.setOfferToken(newOfferToken)
.build()
)
)
.setSubscriptionUpdateParams(
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(currentPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE
// For downgrade: WITH_TIME_PRORATION
// For immediate without credit: CHARGE_FULL_PRICE
)
.build()
)
.build()
Wrong ReplacementMode is a common error. CHARGE_PRORATED_PRICE for downgrade causes immediate charge at new price without compensation — user loses money.
| Scenario | iOS Policy | Android ReplacementMode |
|---|---|---|
| Upgrade | Immediate, prorated credit | CHARGE_PRORATED_PRICE |
| Downgrade | End of period | WITH_TIME_PRORATION |
| Crossgrade (same level) | Next renewal | DEFERRED |
Pending State
Downgrade creates a pending transaction — subscription is purchased but not yet active. On iOS this is .pending in purchase() result. On Android — PENDING in Purchase.purchaseState. You must store this state and notify user about scheduled change.
What's Included in the Work
- Defining tier levels in App Store Connect / Google Play Console
- Client-side logic for purchase with immediate / pending handling
- UI with transition terms for each scenario (up/down/cross)
- Handling
Transaction.updatesto track pending activation - Google Play: correct
ReplacementModefor each transition type - Server-side status sync via App Store Server Notifications / RTDN
Timeline
3–5 days — depends on number of tiers and platforms. Pricing is calculated individually based on requirement analysis.







