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

Commit 96fdb69

Browse files
authored
feat: add renewalInfo in ActiveSubscription (#25)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Shows pending upgrades with a dedicated pending row, icon, and "activates on next renewal" caption. * Subscribe action consolidated with loading state, integrated price/proration display, and unified action area. * **Improvements** * UI and listings now rely on active-subscription data and refreshed subscription state after restore. * iOS renewal details surfaced for clearer upgrade/cancel status. * **Bug Fixes** * More reliable detection of current plan, cancellations, auto-renew, and pending upgrades. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9b3f6b6 commit 96fdb69

File tree

7 files changed

+122
-68
lines changed

7 files changed

+122
-68
lines changed

Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ struct SubscriptionFlowScreen: View {
258258

259259
private func loadPurchases() async {
260260
do {
261-
try await iapStore.getAvailablePurchases()
261+
// Only use activeSubscriptions - demonstrates it contains all necessary info
262+
try await iapStore.getActiveSubscriptions()
262263
} catch {
263264
await MainActor.run {
264265
errorMessage = "Failed to load purchases: \(error.localizedDescription)"
@@ -283,7 +284,7 @@ struct SubscriptionFlowScreen: View {
283284

284285
// MARK: - Subscription Upgrade Flow
285286

286-
private func upgradeSubscription(from currentSubscription: OpenIapPurchase?, to product: OpenIapProduct) async {
287+
private func upgradeSubscription(from currentSubscription: ActiveSubscription?, to product: OpenIapProduct) async {
287288
print("⬆️ [SubscriptionFlow] Starting subscription upgrade")
288289
print(" From: \(currentSubscription?.productId ?? "none")")
289290
print(" To: \(product.id)")
@@ -313,30 +314,34 @@ struct SubscriptionFlowScreen: View {
313314
}
314315

315316
// 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-
}
317+
private func getCurrentSubscription() -> ActiveSubscription? {
318+
// Use activeSubscriptions from store (includes renewalInfo)
319+
let activeSubs = iapStore.activeSubscriptions.filter { $0.isActive }
329320

330321
// Return the subscription with the highest tier (yearly over monthly)
331-
return activeSubscriptions.first { $0.productId.contains("year") } ?? activeSubscriptions.first
322+
return activeSubs.first { $0.productId.contains("year") } ?? activeSubs.first
332323
}
333324

334325
// Determine upgrade possibilities
335-
private func getUpgradeInfo(from currentSubscription: OpenIapPurchase?, to targetProductId: String) -> UpgradeInfo {
326+
private func getUpgradeInfo(from currentSubscription: ActiveSubscription?, to targetProductId: String) -> UpgradeInfo {
336327
guard let current = currentSubscription else {
337328
return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: nil)
338329
}
339330

331+
// Check renewalInfo for pending upgrade
332+
if let renewalInfo = current.renewalInfoIOS,
333+
let pendingUpgrade = renewalInfo.pendingUpgradeProductId {
334+
if pendingUpgrade == targetProductId {
335+
return UpgradeInfo(
336+
canUpgrade: false,
337+
isDowngrade: false,
338+
currentTier: current.productId,
339+
message: "This upgrade will activate on your next renewal date",
340+
isPending: true
341+
)
342+
}
343+
}
344+
340345
// Don't show upgrade for the same product
341346
if current.productId == targetProductId {
342347
return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: current.productId)
@@ -370,8 +375,9 @@ struct SubscriptionFlowScreen: View {
370375
private func restorePurchases() async {
371376
do {
372377
try await iapStore.refreshPurchases(forceSync: true)
378+
try await iapStore.getActiveSubscriptions()
373379
await MainActor.run {
374-
print("✅ [SubscriptionFlow] Restored \(iapStore.iosAvailablePurchases.count) purchases")
380+
print("✅ [SubscriptionFlow] Restored \(iapStore.activeSubscriptions.count) active subscriptions")
375381
}
376382
} catch {
377383
await MainActor.run {
@@ -422,7 +428,7 @@ private extension SubscriptionFlowScreen {
422428

423429
subscriptionIds.forEach { appendIfNeeded($0) }
424430
iapStore.iosProducts.filter { $0.type == .subs }.forEach { appendIfNeeded($0.id) }
425-
iapStore.iosAvailablePurchases.filter { $0.isSubscription }.forEach { appendIfNeeded($0.productId) }
431+
iapStore.activeSubscriptions.forEach { appendIfNeeded($0.productId) }
426432
return orderedIds
427433
}
428434

@@ -435,20 +441,19 @@ private extension SubscriptionFlowScreen {
435441
}
436442

437443
func isSubscribed(productId: String) -> Bool {
438-
guard let purchase = purchase(for: productId) else { return false }
439-
if let expirationTime = purchase.expirationDateIOS {
440-
let expirationDate = Date(timeIntervalSince1970: expirationTime / 1000)
441-
if expirationDate > Date() { return true }
444+
// Check activeSubscriptions first (more accurate)
445+
if let subscription = iapStore.activeSubscriptions.first(where: { $0.productId == productId }) {
446+
return subscription.isActive
442447
}
443-
if purchase.isAutoRenewing { return true }
444-
if purchase.purchaseState == .purchased || purchase.purchaseState == .restored { return true }
445-
return purchase.isSubscription
448+
return false
446449
}
447450

448451
func isCancelled(productId: String) -> Bool {
449-
guard let purchase = purchase(for: productId) else { return false }
450-
let active = isSubscribed(productId: productId)
451-
return purchase.isAutoRenewing == false && active
452+
// Check if subscription is active but won't auto-renew (cancelled)
453+
if let subscription = iapStore.activeSubscriptions.first(where: { $0.productId == productId }) {
454+
return subscription.isActive && subscription.renewalInfoIOS?.willAutoRenew == false
455+
}
456+
return false
452457
}
453458
}
454459

@@ -458,12 +463,14 @@ struct UpgradeInfo {
458463
let isDowngrade: Bool
459464
let currentTier: String?
460465
let message: String?
466+
let isPending: Bool // True if upgrade is pending (already scheduled)
461467

462-
init(canUpgrade: Bool = false, isDowngrade: Bool = false, currentTier: String? = nil, message: String? = nil) {
468+
init(canUpgrade: Bool = false, isDowngrade: Bool = false, currentTier: String? = nil, message: String? = nil, isPending: Bool = false) {
463469
self.canUpgrade = canUpgrade
464470
self.isDowngrade = isDowngrade
465471
self.currentTier = currentTier
466472
self.message = message
473+
self.isPending = isPending
467474
}
468475
}
469476

Example/OpenIapExample/Screens/uis/SubscriptionCard.swift

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -192,52 +192,80 @@ struct SubscriptionCard: View {
192192
// Show upgrade info message if available
193193
if let upgradeInfo = upgradeInfo, let currentTier = upgradeInfo.currentTier {
194194
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()
202-
}
203-
.padding(.horizontal, 12)
204-
.padding(.vertical, 8)
205-
.background(Color.gray.opacity(0.1))
206-
.cornerRadius(6)
195+
// Check if this is a pending upgrade
196+
if upgradeInfo.isPending {
197+
// Show pending upgrade status
198+
VStack(alignment: .leading, spacing: 8) {
199+
HStack {
200+
Image(systemName: "clock.arrow.circlepath")
201+
.foregroundColor(.orange)
202+
Text("Upgrade pending from \(currentTier)")
203+
.font(.caption)
204+
.foregroundColor(.secondary)
205+
Spacer()
206+
}
207+
.padding(.horizontal, 12)
208+
.padding(.vertical, 8)
209+
.background(Color.orange.opacity(0.1))
210+
.cornerRadius(6)
207211

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")
212+
if let message = upgradeInfo.message {
213+
Text(message)
214+
.font(.caption2)
215+
.foregroundColor(.secondary)
216+
.padding(.horizontal, 12)
217+
.frame(maxWidth: .infinity, alignment: .leading)
216218
}
219+
}
220+
} else {
221+
// Show regular upgrade/switch option
222+
HStack {
223+
Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle.fill" : "info.circle.fill")
224+
.foregroundColor(upgradeInfo.canUpgrade ? AppColors.primary : .orange)
225+
Text(upgradeInfo.canUpgrade ? "Upgrade from \(currentTier)" : "Currently subscribed to \(currentTier)")
226+
.font(.caption)
227+
.foregroundColor(.secondary)
228+
Spacer()
229+
}
230+
.padding(.horizontal, 12)
231+
.padding(.vertical, 8)
232+
.background(Color.gray.opacity(0.1))
233+
.cornerRadius(6)
217234

218-
Text(isLoading ? "Processing..." : (upgradeInfo.canUpgrade ? "Upgrade Now" : "Switch Plan"))
219-
.fontWeight(.medium)
235+
Button(action: onSubscribe) {
236+
HStack {
237+
if isLoading {
238+
ProgressView()
239+
.scaleEffect(0.8)
240+
.tint(.white)
241+
} else {
242+
Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle" : "repeat.circle")
243+
}
220244

221-
Spacer()
245+
Text(isLoading ? "Processing..." : (upgradeInfo.canUpgrade ? "Upgrade Now" : "Switch Plan"))
246+
.fontWeight(.medium)
247+
248+
Spacer()
222249

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)
250+
if !isLoading {
251+
VStack(alignment: .trailing, spacing: 2) {
252+
Text(product?.displayPrice ?? "--")
253+
.fontWeight(.semibold)
254+
if upgradeInfo.canUpgrade {
255+
Text("Pro-rated")
256+
.font(.caption2)
257+
.opacity(0.8)
258+
}
231259
}
232260
}
233261
}
262+
.padding()
263+
.background(isLoading ? AppColors.secondary.opacity(0.7) : (upgradeInfo.canUpgrade ? AppColors.primary : AppColors.secondary))
264+
.foregroundColor(.white)
265+
.cornerRadius(8)
234266
}
235-
.padding()
236-
.background(isLoading ? AppColors.secondary.opacity(0.7) : (upgradeInfo.canUpgrade ? AppColors.primary : AppColors.secondary))
237-
.foregroundColor(.white)
238-
.cornerRadius(8)
267+
.disabled(isLoading)
239268
}
240-
.disabled(isLoading)
241269
}
242270
} else {
243271
Button(action: onSubscribe) {

Sources/Helpers/StoreKitTypesBridge.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ enum StoreKitTypesBridge {
170170
return nil
171171
}
172172

173-
private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {
173+
static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? {
174174
guard transaction.productType == .autoRenewable else {
175175
return nil
176176
}

Sources/Models/Types.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ public struct ActiveSubscription: Codable {
175175
public var purchaseToken: String?
176176
/// Required for subscription upgrade/downgrade on Android
177177
public var purchaseTokenAndroid: String?
178+
/// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status,
179+
/// pending upgrades/downgrades, and auto-renewal preferences.
180+
public var renewalInfoIOS: RenewalInfoIOS?
178181
public var transactionDate: Double
179182
public var transactionId: String
180183
public var willExpireSoon: Bool?

Sources/OpenIapModule.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
465465
environment = nil
466466
}
467467

468+
// Fetch renewal info for subscription
469+
let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction)
470+
468471
subscriptions.append(
469472
ActiveSubscription(
470473
autoRenewingAndroid: nil,
@@ -474,6 +477,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
474477
isActive: isActive,
475478
productId: transaction.productID,
476479
purchaseToken: verification.jwsRepresentation,
480+
renewalInfoIOS: renewalInfo,
477481
transactionDate: transaction.purchaseDate.milliseconds,
478482
transactionId: String(transaction.id),
479483
willExpireSoon: willExpireSoon

Sources/OpenIapStore.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,18 @@ public final class OpenIapStore: ObservableObject {
289289

290290
public func getActiveSubscriptions(subscriptionIds: [String]? = nil) async throws {
291291
activeSubscriptions = try await module.getActiveSubscriptions(subscriptionIds)
292+
OpenIapLog.debug("📊 activeSubscriptions: \(activeSubscriptions.count) subscriptions")
293+
294+
// Show renewal info details
295+
for sub in activeSubscriptions where sub.renewalInfoIOS != nil {
296+
if let info = sub.renewalInfoIOS {
297+
OpenIapLog.debug(" 📋 \(sub.productId) renewalInfo:")
298+
OpenIapLog.debug(" • willAutoRenew: \(info.willAutoRenew)")
299+
if let pendingUpgrade = info.pendingUpgradeProductId {
300+
OpenIapLog.debug(" • pendingUpgradeProductId: \(pendingUpgrade) ⚠️ UPGRADE PENDING")
301+
}
302+
}
303+
}
292304
}
293305

294306
public func hasActiveSubscriptions(subscriptionIds: [String]? = nil) async throws -> Bool {

openiap-versions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"apple": "1.2.20",
3-
"gql": "1.2.0"
3+
"gql": "1.2.1"
44
}

0 commit comments

Comments
 (0)