Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 37 additions & 30 deletions Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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)")
Expand Down Expand Up @@ -313,30 +314,34 @@ 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: "This upgrade will activate on your next renewal date",
isPending: true
)
}
}

// Don't show upgrade for the same product
if current.productId == targetProductId {
return UpgradeInfo(canUpgrade: false, isDowngrade: false, currentTier: current.productId)
Expand Down Expand Up @@ -370,8 +375,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 {
Expand Down Expand Up @@ -422,7 +428,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
}

Expand All @@ -435,20 +441,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
}
}

Expand All @@ -458,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
}
}

Expand Down
100 changes: 64 additions & 36 deletions Example/OpenIapExample/Screens/uis/SubscriptionCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,52 +192,80 @@ 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 upgradeInfo.isPending {
// Show pending upgrade status
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)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.1))
.cornerRadius(6)

Button(action: onSubscribe) {
HStack {
if isLoading {
ProgressView()
.scaleEffect(0.8)
.tint(.white)
} else {
Image(systemName: upgradeInfo.canUpgrade ? "arrow.up.circle" : "repeat.circle")
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 {
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) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Helpers/StoreKitTypesBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions Sources/OpenIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Sources/OpenIapStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion openiap-versions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"apple": "1.2.20",
"gql": "1.2.0"
"gql": "1.2.1"
}