Manually calling cancelSubscription() followed by purchaseSubscription() to move a Monthly subscriber to Annual creates a coverage gap, risks double-charging the user during the transition, and can trigger Play Store purchase rejections because Google expects proration via SubscriptionUpdateParams. The error-resilience cost is high: failed upgrades strand users between plans with unclear entitlement state and drive refund requests. Both platforms ship a first-class upgrade/downgrade path — bypassing it is the failure mode here.
Low because it only affects users actively changing tiers, though each affected user risks a double charge.
Use the platform's native upgrade/downgrade mechanism. On iOS, place all subscription products in the same Subscription Group in App Store Connect — Apple handles proration when the user purchases a higher tier from the same group. On Android, pass the existing purchase token through SubscriptionUpdateParams:
val updateParams = SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(currentPurchaseToken)
.setSubscriptionReplacementMode(SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE)
.build()
For RevenueCat, call purchasePackage() with the target package in the same subscription group — the SDK routes to the platform upgrade path automatically.
ID: app-store-iap-subscriptions.subscription-lifecycle.upgrade-downgrade
Severity: low
What to look for: Count all relevant instances and enumerate each. If the app offers multiple subscription tiers (e.g., Basic and Pro, or Monthly and Annual), check how plan changes are handled. Look for: (1) Whether the app shows a plan comparison or upgrade prompt to existing subscribers. (2) Whether tapping "Upgrade to Annual" for an existing Monthly subscriber triggers the platform's upgrade flow rather than a new subscription purchase. For iOS StoreKit 2: product.subscribe(options: Product.SubscriptionInfo.RenewalInfo.UpgradeSubscriptionOption(...)). For RevenueCat: purchasing a package in the same subscription group as the current subscription will automatically trigger an upgrade/downgrade through the platform. The critical failure is an app that cancels the current subscription and starts a new one manually, which can cause a gap in coverage, duplicate charges, or rejected purchases from the store. Also check for a downgrade path — can a Pro subscriber move to Basic without losing access or contacting support?
Pass criteria: App has a single subscription tier (skip), or multi-tier subscriptions use the platform's upgrade/downgrade mechanism (same subscription group on iOS, setSubscriptionUpdateParamsAsync() on Android) rather than cancel-and-resubscribe. At least 1 implementation must be verified.
Fail criteria: Multi-tier subscriptions present but the upgrade path explicitly cancels the current subscription before starting a new one; or no upgrade path exists (users must cancel and resubscribe to change plans).
Skip (N/A) when: App has only a single subscription tier or no subscription at all.
Detail on fail: "UpgradeScreen.tsx calls cancelSubscription() followed by purchaseSubscription() for plan changes — this creates a gap in access and may cause double charges" or "No upgrade/downgrade path exists — plan change instructions tell users to cancel and resubscribe manually"
Remediation: Use the platform's built-in upgrade/downgrade mechanism:
val updateParams = SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(currentPurchaseToken)
.setSubscriptionReplacementMode(SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE)
.build()
purchasePackage() with the target package — RevenueCat handles upgrade/downgrade within the same subscription groupCross-reference: For related patterns and deeper analysis, see the corresponding checks in other AuditBuffet audits covering this domain.