Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit 9a99e5d

Browse files
committed
chore(example): add upgrade/downgrade subscription senario
1 parent 9237421 commit 9a99e5d

File tree

2 files changed

+208
-26
lines changed

2 files changed

+208
-26
lines changed

Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ struct SubscriptionFlowScreen: View {
1313
@State private var isInitialLoading = true
1414

1515
// Product IDs for subscription testing
16+
// Ordered from lowest to highest tier for upgrade scenarios
1617
private let subscriptionIds: [String] = [
17-
"dev.hyo.martie.premium",
18-
"dev.hyo.martie.premium_year"
18+
"dev.hyo.martie.premium", // Monthly subscription (lower tier)
19+
"dev.hyo.martie.premium_year" // Yearly subscription (higher tier)
1920
]
2021

2122
var body: some View {
@@ -70,20 +71,31 @@ struct SubscriptionFlowScreen: View {
7071
} else {
7172
ForEach(productIds, id: \.self) { productId in
7273
let product = product(for: productId)
74+
let currentSubscription = getCurrentSubscription()
75+
let upgradeInfo = getUpgradeInfo(from: currentSubscription, to: productId)
76+
7377
SubscriptionCard(
7478
productId: productId,
7579
product: product,
7680
purchase: purchase(for: productId),
7781
isSubscribed: isSubscribed(productId: productId),
7882
isCancelled: isCancelled(productId: productId),
7983
isLoading: iapStore.status.isPurchasing(productId),
84+
upgradeInfo: upgradeInfo,
8085
onSubscribe: {
8186
let subscribed = isSubscribed(productId: productId)
8287

8388
if subscribed {
8489
Task {
8590
await manageSubscriptions()
8691
}
92+
} else if upgradeInfo.canUpgrade {
93+
// Handle upgrade scenario
94+
if let product = product {
95+
Task {
96+
await upgradeSubscription(from: currentSubscription, to: product)
97+
}
98+
}
8799
} else {
88100
if let product = product {
89101
purchaseProduct(product)
@@ -256,7 +268,7 @@ struct SubscriptionFlowScreen: View {
256268
}
257269

258270
// MARK: - Purchase Flow
259-
271+
260272
private func purchaseProduct(_ product: OpenIapProduct) {
261273
print("🔄 [SubscriptionFlow] Starting subscription purchase for: \(product.id)")
262274
Task {
@@ -268,6 +280,92 @@ struct SubscriptionFlowScreen: View {
268280
}
269281
}
270282
}
283+
284+
// MARK: - Subscription Upgrade Flow
285+
286+
private func upgradeSubscription(from currentSubscription: OpenIapPurchase?, to product: OpenIapProduct) async {
287+
print("⬆️ [SubscriptionFlow] Starting subscription upgrade")
288+
print(" From: \(currentSubscription?.productId ?? "none")")
289+
print(" To: \(product.id)")
290+
print(" iOS will automatically prorate the subscription")
291+
292+
do {
293+
// Request the upgrade purchase
294+
// iOS handles proration automatically when upgrading within the same subscription group
295+
_ = try await iapStore.requestPurchase(
296+
sku: product.id,
297+
type: .subs,
298+
autoFinish: true
299+
)
300+
301+
print("✅ [SubscriptionFlow] Upgrade successful to: \(product.id)")
302+
303+
// Reload purchases to update UI
304+
await loadPurchases()
305+
306+
} catch {
307+
print("❌ [SubscriptionFlow] Upgrade failed: \(error.localizedDescription)")
308+
await MainActor.run {
309+
errorMessage = "Failed to upgrade subscription: \(error.localizedDescription)"
310+
showError = true
311+
}
312+
}
313+
}
314+
315+
// Get current active subscription
316+
private func getCurrentSubscription() -> OpenIapPurchase? {
317+
// Find the currently active subscription with highest priority
318+
let activeSubscriptions = iapStore.iosAvailablePurchases.filter { purchase in
319+
if !purchase.isSubscription { return false }
320+
321+
// Check if subscription is active
322+
if let expirationTime = purchase.expirationDateIOS {
323+
let expirationDate = Date(timeIntervalSince1970: expirationTime / 1000)
324+
return expirationDate > Date() && purchase.isAutoRenewing
325+
}
326+
327+
return purchase.purchaseState == .purchased && purchase.isAutoRenewing
328+
}
329+
330+
// Return the subscription with the highest tier (yearly over monthly)
331+
return activeSubscriptions.first { $0.productId.contains("year") } ?? activeSubscriptions.first
332+
}
333+
334+
// Determine upgrade possibilities
335+
private func getUpgradeInfo(from currentSubscription: OpenIapPurchase?, to targetProductId: String) -> UpgradeInfo {
336+
guard let current = currentSubscription else {
337+
return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: nil)
338+
}
339+
340+
// Don't show upgrade for the same product
341+
if current.productId == targetProductId {
342+
return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: current.productId)
343+
}
344+
345+
// Determine tier based on product ID
346+
let currentTier = getSubscriptionTier(current.productId)
347+
let targetTier = getSubscriptionTier(targetProductId)
348+
349+
let canUpgrade = targetTier > currentTier
350+
let isDowngrade = targetTier < currentTier
351+
352+
return UpgradeInfo(
353+
canUpgrade: canUpgrade,
354+
isDowngrade: isDowngrade,
355+
currentTier: current.productId,
356+
message: canUpgrade ? "Upgrade available" : (isDowngrade ? "Downgrade option" : nil)
357+
)
358+
}
359+
360+
// Get subscription tier level (higher number = higher tier)
361+
private func getSubscriptionTier(_ productId: String) -> Int {
362+
if productId.contains("year") || productId.contains("annual") {
363+
return 2 // Yearly is higher tier
364+
} else if productId.contains("month") || productId.contains("premium") {
365+
return 1 // Monthly is lower tier
366+
}
367+
return 0 // Unknown tier
368+
}
271369

272370
private func restorePurchases() async {
273371
do {
@@ -354,6 +452,21 @@ private extension SubscriptionFlowScreen {
354452
}
355453
}
356454

455+
// MARK: - Upgrade Info Model
456+
struct UpgradeInfo {
457+
let canUpgrade: Bool
458+
let isDowngrade: Bool
459+
let currentTier: String?
460+
let message: String?
461+
462+
init(canUpgrade: Bool = false, isDowngrade: Bool = false, currentTier: String? = nil, message: String? = nil) {
463+
self.canUpgrade = canUpgrade
464+
self.isDowngrade = isDowngrade
465+
self.currentTier = currentTier
466+
self.message = message
467+
}
468+
}
469+
357470
@available(iOS 15.0, *)
358471
@MainActor
359472
private extension SubscriptionFlowScreen {

Example/OpenIapExample/Screens/uis/SubscriptionCard.swift

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct SubscriptionCard: View {
88
let isSubscribed: Bool
99
let isCancelled: Bool
1010
let isLoading: Bool
11+
var upgradeInfo: UpgradeInfo? = nil
1112
let onSubscribe: () -> Void
1213
let onManage: () -> Void
1314

@@ -33,7 +34,7 @@ struct SubscriptionCard: View {
3334
HStack {
3435
Text(product?.title ?? productId)
3536
.font(.headline)
36-
37+
3738
if isSubscribed {
3839
Label("Subscribed", systemImage: "checkmark.seal.fill")
3940
.font(.caption)
@@ -42,6 +43,22 @@ struct SubscriptionCard: View {
4243
.padding(.vertical, 2)
4344
.background(AppColors.success.opacity(0.2))
4445
.cornerRadius(4)
46+
} else if let upgradeInfo = upgradeInfo, upgradeInfo.canUpgrade {
47+
Label("Upgrade", systemImage: "arrow.up.circle.fill")
48+
.font(.caption)
49+
.foregroundColor(AppColors.primary)
50+
.padding(.horizontal, 8)
51+
.padding(.vertical, 2)
52+
.background(AppColors.primary.opacity(0.2))
53+
.cornerRadius(4)
54+
} else if let upgradeInfo = upgradeInfo, upgradeInfo.isDowngrade {
55+
Label("Downgrade", systemImage: "arrow.down.circle")
56+
.font(.caption)
57+
.foregroundColor(.orange)
58+
.padding(.horizontal, 8)
59+
.padding(.vertical, 2)
60+
.background(Color.orange.opacity(0.2))
61+
.cornerRadius(4)
4562
}
4663
}
4764

@@ -172,32 +189,84 @@ struct SubscriptionCard: View {
172189
}
173190
}
174191
} else {
175-
Button(action: onSubscribe) {
176-
HStack {
177-
if isLoading {
178-
ProgressView()
179-
.scaleEffect(0.8)
180-
.tint(.white)
181-
} else {
182-
Image(systemName: "repeat.circle")
192+
// Show upgrade info message if available
193+
if let upgradeInfo = upgradeInfo, let currentTier = upgradeInfo.currentTier {
194+
VStack(spacing: 8) {
195+
HStack {
196+
Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle.fill" : "info.circle.fill")
197+
.foregroundColor(upgradeInfo.canUpgrade ? AppColors.primary : .orange)
198+
Text(upgradeInfo.canUpgrade ? "Upgrade from \(currentTier)" : "Currently subscribed to \(currentTier)")
199+
.font(.caption)
200+
.foregroundColor(.secondary)
201+
Spacer()
183202
}
184-
185-
Text(isLoading ? "Processing..." : "Subscribe")
186-
.fontWeight(.medium)
187-
188-
Spacer()
189-
190-
if !isLoading {
191-
Text(product?.displayPrice ?? "--")
192-
.fontWeight(.semibold)
203+
.padding(.horizontal, 12)
204+
.padding(.vertical, 8)
205+
.background(Color.gray.opacity(0.1))
206+
.cornerRadius(6)
207+
208+
Button(action: onSubscribe) {
209+
HStack {
210+
if isLoading {
211+
ProgressView()
212+
.scaleEffect(0.8)
213+
.tint(.white)
214+
} else {
215+
Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle" : "repeat.circle")
216+
}
217+
218+
Text(isLoading ? "Processing..." : (upgradeInfo.canUpgrade ? "Upgrade Now" : "Switch Plan"))
219+
.fontWeight(.medium)
220+
221+
Spacer()
222+
223+
if !isLoading {
224+
VStack(alignment: .trailing, spacing: 2) {
225+
Text(product?.displayPrice ?? "--")
226+
.fontWeight(.semibold)
227+
if upgradeInfo.canUpgrade {
228+
Text("Pro-rated")
229+
.font(.caption2)
230+
.opacity(0.8)
231+
}
232+
}
233+
}
234+
}
235+
.padding()
236+
.background(isLoading ? AppColors.secondary.opacity(0.7) : (upgradeInfo.canUpgrade ? AppColors.primary : AppColors.secondary))
237+
.foregroundColor(.white)
238+
.cornerRadius(8)
239+
}
240+
.disabled(isLoading)
241+
}
242+
} else {
243+
Button(action: onSubscribe) {
244+
HStack {
245+
if isLoading {
246+
ProgressView()
247+
.scaleEffect(0.8)
248+
.tint(.white)
249+
} else {
250+
Image(systemName: "repeat.circle")
251+
}
252+
253+
Text(isLoading ? "Processing..." : "Subscribe")
254+
.fontWeight(.medium)
255+
256+
Spacer()
257+
258+
if !isLoading {
259+
Text(product?.displayPrice ?? "--")
260+
.fontWeight(.semibold)
261+
}
193262
}
263+
.padding()
264+
.background(isLoading ? AppColors.secondary.opacity(0.7) : AppColors.secondary)
265+
.foregroundColor(.white)
266+
.cornerRadius(8)
194267
}
195-
.padding()
196-
.background(isLoading ? AppColors.secondary.opacity(0.7) : AppColors.secondary)
197-
.foregroundColor(.white)
198-
.cornerRadius(8)
268+
.disabled(isLoading)
199269
}
200-
.disabled(isLoading)
201270
}
202271
}
203272
.padding()

0 commit comments

Comments
 (0)