/* See LICENSE folder for this sample’s licensing information. Abstract: The store class is responsible for requesting products from the App Store and starting purchases. */ import Foundation import StoreKit import SwiftUI typealias Transaction = StoreKit.Transaction typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState public enum StoreError: Error { case failedVerification } class IAPManager: ObservableObject { @Published private(set) var showIAP = false @Published private(set) var showIAPWarning = false @Published private(set) var subscriptions = [Product: (status: [Product.SubscriptionInfo.Status], renewalInfo: RenewalInfo)?]() @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() @Published private(set) var isLoadingSubscriptions = false public var sortedSubscriptionKeysByPriceOptions: [Product] { subscriptions.keys.sorted(by: { $0.price < $1.price }) } public var currentSubscription: Product? { let sortedProducts = subscriptions.keys.sorted(by: { $0.price > $1.price }) // first see if we have a product + sub that is set to autorenew for product in sortedProducts { if let _value = subscriptions[product]??.renewalInfo { if _value.willAutoRenew { return product } } } // if no auto renew then return // highest priced that has a sub status for product in sortedProducts { if let _ = subscriptions[product]??.status { return product } } return nil } // for all products return the one that is set // to auto renew public var nextRenewllOption: Product? { if let currentSubscription = currentSubscription, let info = subscriptions[currentSubscription], let status = info?.status { for aStatus in status { if let renewalInfo = try? checkVerified(aStatus.renewalInfo), renewalInfo.willAutoRenew { if let renewToProduct = subscriptions.first(where: { $0.key.id == renewalInfo.autoRenewPreference })?.key { return renewToProduct } } } } return nil } var updateListenerTask: Task? = nil public var daysLeftBeforeIAP: Int { let daysSinceInstall = Calendar.current.dateComponents([.day, .hour, .minute, .second], from: firstLaunchDate, to: Date()) if let days = daysSinceInstall.day { return 30 - days } return 0 } public var expireDate: Date? { Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) ?? nil } private let iapIdentifiers = Set([ // "com.88oakapps.ifeel.IAP.subscriptions.weekly", "com.88oakapps.ifeel.IAP.subscriptions.monthly", "com.88oakapps.ifeel.IAP.subscriptions.yearly" ]) var expireOnTimer: Timer? init() { isLoadingSubscriptions = true //Start a transaction listener as close to app launch as possible so you don't miss any transactions. updateListenerTask = listenForTransactions() refresh() setUpdateTimer() } deinit { updateListenerTask?.cancel() } func setUpdateTimer() { if let expireDate = expireDate { expireOnTimer = Timer.init(fire: expireDate, interval: 0, repeats: false, block: { _ in self.decideShowIAP() self.decideShowIAPWarning() }) RunLoop.main.add(expireOnTimer!, forMode: .common) } else { if let expireOnTimer = expireOnTimer { expireOnTimer.invalidate() } } } func refresh() { Task { //During store initialization, request products from the App Store. await requestProducts() //Deliver products that the customer purchases. await updateCustomerProductStatus() decideShowIAP() decideShowIAPWarning() DispatchQueue.main.async { self.isLoadingSubscriptions = false } } } func decideShowIAP() { // if we have a sub in the subscriptions dict // then we dont need to show for (_, value) in self.subscriptions { if value != nil { DispatchQueue.main.async { self.showIAP = false } return } } var tmpShowIAP = true // if its passed 30 days with no sub if daysLeftBeforeIAP <= 0 { tmpShowIAP = true } else { tmpShowIAP = false } DispatchQueue.main.async { self.showIAP = tmpShowIAP } } func decideShowIAPWarning() { DispatchQueue.main.async { if self.currentSubscription != nil { self.showIAPWarning = false } else { self.showIAPWarning = true } } } func listenForTransactions() -> Task { return Task.detached { //Iterate through any transactions that don't come from a direct call to `purchase()`. for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) //Deliver products to the user. await self.updateCustomerProductStatus() //Always finish a transaction. await transaction.finish() } catch { //StoreKit has a transaction that fails verification. Don't deliver content to the user. print("Transaction failed verification") } } } } // fetch all available iap from remote and store locally // in subscriptions @MainActor func requestProducts() async { do { subscriptions.removeAll() //Request products from the App Store using the identifiers that the Products.plist file defines. let storeProducts = try await Product.products(for: iapIdentifiers) //Filter the products into categories based on their type. for product in storeProducts { switch product.type { case .consumable: break case .nonConsumable: break case .autoRenewable: subscriptions.updateValue(nil, forKey: product) case .nonRenewable: break default: //Ignore this product. print("Unknown product") } } } catch { print("Failed product request from the App Store server: \(error)") } } @MainActor func updateCustomerProductStatus() async { var purchasedSubscriptions: [Product] = [] // Iterate through all of the user's purchased products. for await result in Transaction.currentEntitlements { do { //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. let transaction = try checkVerified(result) //Check the `productType` of the transaction and get the corresponding product from the store. switch transaction.productType { case .nonConsumable: break case .nonRenewable: break case .autoRenewable: print("subscriptions2 \(subscriptions)") if let subscription = subscriptions.first(where: { $0.key.id == transaction.productID }) { purchasedSubscriptions.append(subscription.key) } default: break } } catch { print() } } print("purchasedSubscriptions \(purchasedSubscriptions)") if !purchasedSubscriptions.isEmpty { self.showIAP = false self.showIAPWarning = false } for sub in purchasedSubscriptions { guard let statuses = try? await sub.subscription?.status else { return } for status in statuses { switch status.state { case .expired, .revoked: continue default: if let renewalInfo = try? checkVerified(status.renewalInfo) { subscriptions.updateValue((statuses, renewalInfo), forKey: sub) } } } } } func purchase(_ product: Product) async throws -> Transaction? { //Begin purchasing the `Product` the user selects. let result = try await product.purchase() switch result { case .success(let verification): //Check whether the transaction is verified. If it isn't, //this function rethrows the verification error. let transaction = try checkVerified(verification) //The transaction is verified. Deliver content to the user. await updateCustomerProductStatus() //Always finish a transaction. await transaction.finish() decideShowIAP() decideShowIAPWarning() return transaction case .userCancelled, .pending: return nil default: return nil } } func isPurchased(_ product: Product) async throws -> Bool { //Determine whether the user purchases a given product. switch product.type { case .nonRenewable: return false case .nonConsumable: return false case .autoRenewable: return subscriptions.keys.contains(product) default: return false } } func checkVerified(_ result: VerificationResult) throws -> T { //Check whether the JWS passes StoreKit verification. switch result { case .unverified: //StoreKit parses the JWS, but it fails verification. throw StoreError.failedVerification case .verified(let safe): //The result is verified. Return the unwrapped value. return safe } } func sortByPrice(_ products: [Product]) -> [Product] { products.sorted(by: { return $0.price < $1.price }) } func colorForIAPButton(iapIdentifier: String) -> Color { if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.weekly" { return DefaultMoodTint.color(forMood: .horrible) } else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.monthly" { return DefaultMoodTint.color(forMood: .average) } else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.yearly" { return DefaultMoodTint.color(forMood: .great) } return .blue } }