From b309e9635322f85a333c3443aba5acec2b5023e2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 04:14:21 +0900 Subject: [PATCH 1/3] feat: add renewalInfo in ActiveSubscription --- .../Screens/SubscriptionFlowScreen.swift | 62 ++++++------ .../Screens/uis/SubscriptionCard.swift | 96 ++++++++++++------- Sources/Helpers/StoreKitTypesBridge.swift | 2 +- Sources/Models/Types.swift | 3 + Sources/OpenIapModule.swift | 4 + Sources/OpenIapStore.swift | 12 +++ 6 files changed, 113 insertions(+), 66 deletions(-) diff --git a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index 87b5c93..c9ba2e3 100644 --- a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -258,7 +258,8 @@ struct SubscriptionFlowScreen: View { private func loadPurchases() async { do { - try await iapStore.getAvailablePurchases() + // Only use activeSubscriptions - demonstrates it contains all necessary info + try await iapStore.getActiveSubscriptions() } catch { await MainActor.run { errorMessage = "Failed to load purchases: \(error.localizedDescription)" @@ -283,7 +284,7 @@ struct SubscriptionFlowScreen: View { // MARK: - Subscription Upgrade Flow - private func upgradeSubscription(from currentSubscription: OpenIapPurchase?, to product: OpenIapProduct) async { + private func upgradeSubscription(from currentSubscription: ActiveSubscription?, to product: OpenIapProduct) async { print("⬆️ [SubscriptionFlow] Starting subscription upgrade") print(" From: \(currentSubscription?.productId ?? "none")") print(" To: \(product.id)") @@ -313,30 +314,33 @@ struct SubscriptionFlowScreen: View { } // Get current active subscription - private func getCurrentSubscription() -> OpenIapPurchase? { - // Find the currently active subscription with highest priority - let activeSubscriptions = iapStore.iosAvailablePurchases.filter { purchase in - if !purchase.isSubscription { return false } - - // Check if subscription is active - if let expirationTime = purchase.expirationDateIOS { - let expirationDate = Date(timeIntervalSince1970: expirationTime / 1000) - return expirationDate > Date() && purchase.isAutoRenewing - } - - return purchase.purchaseState == .purchased && purchase.isAutoRenewing - } + private func getCurrentSubscription() -> ActiveSubscription? { + // Use activeSubscriptions from store (includes renewalInfo) + let activeSubs = iapStore.activeSubscriptions.filter { $0.isActive } // Return the subscription with the highest tier (yearly over monthly) - return activeSubscriptions.first { $0.productId.contains("year") } ?? activeSubscriptions.first + return activeSubs.first { $0.productId.contains("year") } ?? activeSubs.first } // Determine upgrade possibilities - private func getUpgradeInfo(from currentSubscription: OpenIapPurchase?, to targetProductId: String) -> UpgradeInfo { + private func getUpgradeInfo(from currentSubscription: ActiveSubscription?, to targetProductId: String) -> UpgradeInfo { guard let current = currentSubscription else { return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: nil) } + // Check renewalInfo for pending upgrade + if let renewalInfo = current.renewalInfoIOS, + let pendingUpgrade = renewalInfo.pendingUpgradeProductId { + if pendingUpgrade == targetProductId { + return UpgradeInfo( + canUpgrade: false, + isDowngrade: false, + currentTier: current.productId, + message: "Upgrade pending to this plan" + ) + } + } + // Don't show upgrade for the same product if current.productId == targetProductId { return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: current.productId) @@ -370,8 +374,9 @@ struct SubscriptionFlowScreen: View { private func restorePurchases() async { do { try await iapStore.refreshPurchases(forceSync: true) + try await iapStore.getActiveSubscriptions() await MainActor.run { - print("✅ [SubscriptionFlow] Restored \(iapStore.iosAvailablePurchases.count) purchases") + print("✅ [SubscriptionFlow] Restored \(iapStore.activeSubscriptions.count) active subscriptions") } } catch { await MainActor.run { @@ -422,7 +427,7 @@ private extension SubscriptionFlowScreen { subscriptionIds.forEach { appendIfNeeded($0) } iapStore.iosProducts.filter { $0.type == .subs }.forEach { appendIfNeeded($0.id) } - iapStore.iosAvailablePurchases.filter { $0.isSubscription }.forEach { appendIfNeeded($0.productId) } + iapStore.activeSubscriptions.forEach { appendIfNeeded($0.productId) } return orderedIds } @@ -435,20 +440,19 @@ private extension SubscriptionFlowScreen { } func isSubscribed(productId: String) -> Bool { - guard let purchase = purchase(for: productId) else { return false } - if let expirationTime = purchase.expirationDateIOS { - let expirationDate = Date(timeIntervalSince1970: expirationTime / 1000) - if expirationDate > Date() { return true } + // Check activeSubscriptions first (more accurate) + if let subscription = iapStore.activeSubscriptions.first(where: { $0.productId == productId }) { + return subscription.isActive } - if purchase.isAutoRenewing { return true } - if purchase.purchaseState == .purchased || purchase.purchaseState == .restored { return true } - return purchase.isSubscription + return false } func isCancelled(productId: String) -> Bool { - guard let purchase = purchase(for: productId) else { return false } - let active = isSubscribed(productId: productId) - return purchase.isAutoRenewing == false && active + // Check if subscription is active but won't auto-renew (cancelled) + if let subscription = iapStore.activeSubscriptions.first(where: { $0.productId == productId }) { + return subscription.isActive && subscription.renewalInfoIOS?.willAutoRenew == false + } + return false } } diff --git a/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift b/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift index 3cebd2f..d1a08d5 100644 --- a/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift +++ b/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift @@ -192,52 +192,76 @@ struct SubscriptionCard: View { // Show upgrade info message if available if let upgradeInfo = upgradeInfo, let currentTier = upgradeInfo.currentTier { VStack(spacing: 8) { - HStack { - Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle.fill" : "info.circle.fill") - .foregroundColor(upgradeInfo.canUpgrade ? AppColors.primary : .orange) - Text(upgradeInfo.canUpgrade ? "Upgrade from \(currentTier)" : "Currently subscribed to \(currentTier)") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) + // Check if this is a pending upgrade + if let message = upgradeInfo.message, message.contains("pending") { + // Show pending upgrade status + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.orange) + Text("Upgrade pending from \(currentTier)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) - Button(action: onSubscribe) { + Text("This plan will activate on your next renewal date") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // Show regular upgrade/switch option HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - .tint(.white) - } else { - Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle" : "repeat.circle") - } + Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle.fill" : "info.circle.fill") + .foregroundColor(upgradeInfo.canUpgrade ? AppColors.primary : .orange) + Text(upgradeInfo.canUpgrade ? "Upgrade from \(currentTier)" : "Currently subscribed to \(currentTier)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) - Text(isLoading ? "Processing..." : (upgradeInfo.canUpgrade ? "Upgrade Now" : "Switch Plan")) - .fontWeight(.medium) + Button(action: onSubscribe) { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + .tint(.white) + } else { + Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle" : "repeat.circle") + } - Spacer() + Text(isLoading ? "Processing..." : (upgradeInfo.canUpgrade ? "Upgrade Now" : "Switch Plan")) + .fontWeight(.medium) + + Spacer() - if !isLoading { - VStack(alignment: .trailing, spacing: 2) { - Text(product?.displayPrice ?? "--") - .fontWeight(.semibold) - if upgradeInfo.canUpgrade { - Text("Pro-rated") - .font(.caption2) - .opacity(0.8) + if !isLoading { + VStack(alignment: .trailing, spacing: 2) { + Text(product?.displayPrice ?? "--") + .fontWeight(.semibold) + if upgradeInfo.canUpgrade { + Text("Pro-rated") + .font(.caption2) + .opacity(0.8) + } } } } + .padding() + .background(isLoading ? AppColors.secondary.opacity(0.7) : (upgradeInfo.canUpgrade ? AppColors.primary : AppColors.secondary)) + .foregroundColor(.white) + .cornerRadius(8) } - .padding() - .background(isLoading ? AppColors.secondary.opacity(0.7) : (upgradeInfo.canUpgrade ? AppColors.primary : AppColors.secondary)) - .foregroundColor(.white) - .cornerRadius(8) + .disabled(isLoading) } - .disabled(isLoading) } } else { Button(action: onSubscribe) { diff --git a/Sources/Helpers/StoreKitTypesBridge.swift b/Sources/Helpers/StoreKitTypesBridge.swift index 5be9235..cd005eb 100644 --- a/Sources/Helpers/StoreKitTypesBridge.swift +++ b/Sources/Helpers/StoreKitTypesBridge.swift @@ -170,7 +170,7 @@ enum StoreKitTypesBridge { return nil } - private static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? { + static func subscriptionRenewalInfoIOS(for transaction: StoreKit.Transaction) async -> RenewalInfoIOS? { guard transaction.productType == .autoRenewable else { return nil } diff --git a/Sources/Models/Types.swift b/Sources/Models/Types.swift index 5ad2f50..0a655eb 100644 --- a/Sources/Models/Types.swift +++ b/Sources/Models/Types.swift @@ -175,6 +175,9 @@ public struct ActiveSubscription: Codable { public var purchaseToken: String? /// Required for subscription upgrade/downgrade on Android public var purchaseTokenAndroid: String? + /// Renewal information from StoreKit 2 (iOS only). Contains details about subscription renewal status, + /// pending upgrades/downgrades, and auto-renewal preferences. + public var renewalInfoIOS: RenewalInfoIOS? public var transactionDate: Double public var transactionId: String public var willExpireSoon: Bool? diff --git a/Sources/OpenIapModule.swift b/Sources/OpenIapModule.swift index 2fa471a..b6d4eff 100644 --- a/Sources/OpenIapModule.swift +++ b/Sources/OpenIapModule.swift @@ -465,6 +465,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { environment = nil } + // Fetch renewal info for subscription + let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) + subscriptions.append( ActiveSubscription( autoRenewingAndroid: nil, @@ -474,6 +477,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { isActive: isActive, productId: transaction.productID, purchaseToken: verification.jwsRepresentation, + renewalInfoIOS: renewalInfo, transactionDate: transaction.purchaseDate.milliseconds, transactionId: String(transaction.id), willExpireSoon: willExpireSoon diff --git a/Sources/OpenIapStore.swift b/Sources/OpenIapStore.swift index 2b2f6b7..bece748 100644 --- a/Sources/OpenIapStore.swift +++ b/Sources/OpenIapStore.swift @@ -289,6 +289,18 @@ public final class OpenIapStore: ObservableObject { public func getActiveSubscriptions(subscriptionIds: [String]? = nil) async throws { activeSubscriptions = try await module.getActiveSubscriptions(subscriptionIds) + OpenIapLog.debug("📊 activeSubscriptions: \(activeSubscriptions.count) subscriptions") + + // Show renewal info details + for sub in activeSubscriptions where sub.renewalInfoIOS != nil { + if let info = sub.renewalInfoIOS { + OpenIapLog.debug(" 📋 \(sub.productId) renewalInfo:") + OpenIapLog.debug(" • willAutoRenew: \(info.willAutoRenew)") + if let pendingUpgrade = info.pendingUpgradeProductId { + OpenIapLog.debug(" • pendingUpgradeProductId: \(pendingUpgrade) ⚠️ UPGRADE PENDING") + } + } + } } public func hasActiveSubscriptions(subscriptionIds: [String]? = nil) async throws -> Bool { From bdda284db54a00da1fdb885b5411217af78939f6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 04:21:16 +0900 Subject: [PATCH 2/3] chore(deps): openiap-gql@1.2.1 --- openiap-versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openiap-versions.json b/openiap-versions.json index 2663ad1..6e34a25 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,4 +1,4 @@ { "apple": "1.2.20", - "gql": "1.2.0" + "gql": "1.2.1" } From df875ea9595a2b1b3c3ed5d7485f8eea51987701 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 16 Oct 2025 04:24:59 +0900 Subject: [PATCH 3/3] fix: apply code review --- .../Screens/SubscriptionFlowScreen.swift | 7 +++- .../Screens/uis/SubscriptionCard.swift | 40 ++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift index c9ba2e3..07b4a2e 100644 --- a/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift +++ b/Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift @@ -336,7 +336,8 @@ struct SubscriptionFlowScreen: View { canUpgrade: false, isDowngrade: false, currentTier: current.productId, - message: "Upgrade pending to this plan" + message: "This upgrade will activate on your next renewal date", + isPending: true ) } } @@ -462,12 +463,14 @@ struct UpgradeInfo { let isDowngrade: Bool let currentTier: String? let message: String? + let isPending: Bool // True if upgrade is pending (already scheduled) - init(canUpgrade: Bool = false, isDowngrade: Bool = false, currentTier: String? = nil, message: String? = nil) { + init(canUpgrade: Bool = false, isDowngrade: Bool = false, currentTier: String? = nil, message: String? = nil, isPending: Bool = false) { self.canUpgrade = canUpgrade self.isDowngrade = isDowngrade self.currentTier = currentTier self.message = message + self.isPending = isPending } } diff --git a/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift b/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift index d1a08d5..7256af6 100644 --- a/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift +++ b/Example/OpenIapExample/Screens/uis/SubscriptionCard.swift @@ -193,26 +193,30 @@ struct SubscriptionCard: View { if let upgradeInfo = upgradeInfo, let currentTier = upgradeInfo.currentTier { VStack(spacing: 8) { // Check if this is a pending upgrade - if let message = upgradeInfo.message, message.contains("pending") { + if upgradeInfo.isPending { // Show pending upgrade status - HStack { - Image(systemName: "clock.arrow.circlepath") - .foregroundColor(.orange) - Text("Upgrade pending from \(currentTier)") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color.orange.opacity(0.1)) - .cornerRadius(6) - - Text("This plan will activate on your next renewal date") - .font(.caption2) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.orange) + Text("Upgrade pending from \(currentTier)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + + if let message = upgradeInfo.message { + Text(message) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } else { // Show regular upgrade/switch option HStack {