|
|
@@ -0,0 +1,341 @@
|
|
|
+import Combine
|
|
|
+import Foundation
|
|
|
+import StoreKit
|
|
|
+
|
|
|
+enum PurchaseError: Equatable {
|
|
|
+ case productsLoadTimeout
|
|
|
+ case subscriptionUnavailable
|
|
|
+ case planUnavailable
|
|
|
+ case purchaseNotVerified
|
|
|
+ case purchasePending
|
|
|
+ case noActiveSubscriptions
|
|
|
+ case generic
|
|
|
+
|
|
|
+ var message: String {
|
|
|
+ switch self {
|
|
|
+ case .productsLoadTimeout:
|
|
|
+ "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
|
|
|
+ case .subscriptionUnavailable:
|
|
|
+ "This subscription is currently unavailable. Please try again in a moment."
|
|
|
+ case .planUnavailable:
|
|
|
+ "This plan isn't available right now. Please try again later."
|
|
|
+ case .purchaseNotVerified:
|
|
|
+ "Apple couldn't verify this purchase."
|
|
|
+ case .purchasePending:
|
|
|
+ "This purchase is waiting for approval (for example, Ask to Buy)."
|
|
|
+ case .noActiveSubscriptions:
|
|
|
+ "No active subscriptions were found for this Apple ID."
|
|
|
+ case .generic:
|
|
|
+ "Something went wrong with your subscription. Please try again."
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@MainActor
|
|
|
+final class SubscriptionManager: ObservableObject {
|
|
|
+ private enum ProductLoadError: Error {
|
|
|
+ case timeout
|
|
|
+ }
|
|
|
+
|
|
|
+ @Published private(set) var productsByID: [String: Product] = [:]
|
|
|
+ @Published private(set) var isLoadingProducts = false
|
|
|
+ @Published private(set) var purchasingPlan: PaywallPlan?
|
|
|
+ @Published private(set) var hasPremiumAccess = false
|
|
|
+ @Published var purchaseError: PurchaseError?
|
|
|
+
|
|
|
+ var hasAllProductsLoaded: Bool {
|
|
|
+ SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var inFlightProductLoad: Task<Void, Never>?
|
|
|
+
|
|
|
+ init() {
|
|
|
+ Task { await listenForTransactionUpdates() }
|
|
|
+ Task { await refreshPremiumAccess() }
|
|
|
+ Task { await loadProducts(presentLoadingUI: false) }
|
|
|
+ }
|
|
|
+
|
|
|
+ func loadProducts(presentLoadingUI: Bool? = nil) async {
|
|
|
+ if let existing = inFlightProductLoad {
|
|
|
+ await existing.value
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let uiFlag = presentLoadingUI
|
|
|
+ let task = Task { @MainActor in
|
|
|
+ await performLoadProducts(presentLoadingUI: uiFlag)
|
|
|
+ }
|
|
|
+ inFlightProductLoad = task
|
|
|
+ await task.value
|
|
|
+ inFlightProductLoad = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ func product(for plan: PaywallPlan) -> Product? {
|
|
|
+ productsByID[plan.productID]
|
|
|
+ }
|
|
|
+
|
|
|
+ func mainPrice(for plan: PaywallPlan) -> String {
|
|
|
+ product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
|
|
|
+ }
|
|
|
+
|
|
|
+ func secondaryPrice(for plan: PaywallPlan) -> String {
|
|
|
+ guard let product = product(for: plan) else {
|
|
|
+ return plan.fallbackSecondaryPrice
|
|
|
+ }
|
|
|
+
|
|
|
+ let formatStyle = product.priceFormatStyle
|
|
|
+
|
|
|
+ switch plan {
|
|
|
+ case .weekly:
|
|
|
+ let doubled = product.price * 2
|
|
|
+ return "\(doubled.formatted(formatStyle)) / week"
|
|
|
+ case .monthly:
|
|
|
+ let weekly = product.price / Decimal(4.33)
|
|
|
+ return "\(weekly.formatted(formatStyle)) / week"
|
|
|
+ case .yearly:
|
|
|
+ let weekly = product.price / Decimal(52)
|
|
|
+ return "\(weekly.formatted(formatStyle)) / week"
|
|
|
+ case .lifetime:
|
|
|
+ let doubled = product.price * 2
|
|
|
+ return doubled.formatted(formatStyle)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func billingDescription(for plan: PaywallPlan) -> String {
|
|
|
+ guard let product = product(for: plan) else {
|
|
|
+ return plan.fallbackBillingDescription
|
|
|
+ }
|
|
|
+
|
|
|
+ if let subscription = product.subscription,
|
|
|
+ let intro = subscription.introductoryOffer,
|
|
|
+ intro.paymentMode == .freeTrial {
|
|
|
+ let trialPeriod = formattedPeriod(intro.period)
|
|
|
+ let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
|
|
|
+ return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
|
|
|
+ }
|
|
|
+
|
|
|
+ switch plan {
|
|
|
+ case .weekly:
|
|
|
+ return "Billed at \(product.displayPrice) every week"
|
|
|
+ case .monthly:
|
|
|
+ return "Billed at \(product.displayPrice) every month"
|
|
|
+ case .yearly:
|
|
|
+ return "Billed at \(product.displayPrice) every year"
|
|
|
+ case .lifetime:
|
|
|
+ return "One-time payment of \(product.displayPrice)"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
|
+ purchaseError = nil
|
|
|
+
|
|
|
+ var product = productsByID[plan.productID]
|
|
|
+ if product == nil {
|
|
|
+ await loadProducts(presentLoadingUI: false)
|
|
|
+ product = productsByID[plan.productID]
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let product else {
|
|
|
+ let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
|
|
|
+ if missingProductIDs.isEmpty {
|
|
|
+ purchaseError = .subscriptionUnavailable
|
|
|
+ } else {
|
|
|
+ purchaseError = .planUnavailable
|
|
|
+ #if DEBUG
|
|
|
+ print(
|
|
|
+ """
|
|
|
+ [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
|
|
|
+ To test locally, attach `Paywall.storekit` in Xcode:
|
|
|
+ Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
|
|
|
+ """
|
|
|
+ )
|
|
|
+ #endif
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ purchasingPlan = plan
|
|
|
+ defer { purchasingPlan = nil }
|
|
|
+
|
|
|
+ do {
|
|
|
+ let result = try await product.purchase()
|
|
|
+ switch result {
|
|
|
+ case .success(let verification):
|
|
|
+ guard case .verified(let transaction) = verification else {
|
|
|
+ purchaseError = .purchaseNotVerified
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ return hasPremiumAccess
|
|
|
+ case .userCancelled:
|
|
|
+ return false
|
|
|
+ case .pending:
|
|
|
+ purchaseError = .purchasePending
|
|
|
+ return false
|
|
|
+ @unknown default:
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ purchaseError = .generic
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func restorePurchases() async {
|
|
|
+ purchaseError = nil
|
|
|
+ do {
|
|
|
+ try await AppStore.sync()
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ if !hasPremiumAccess {
|
|
|
+ purchaseError = .noActiveSubscriptions
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ purchaseError = .generic
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
|
+ let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
|
|
|
+ let shouldShowLoading = presentLoadingUI ?? missingAny
|
|
|
+
|
|
|
+ if !missingAny, presentLoadingUI != true {
|
|
|
+ purchaseError = nil
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if shouldShowLoading {
|
|
|
+ isLoadingProducts = true
|
|
|
+ }
|
|
|
+ defer {
|
|
|
+ if shouldShowLoading {
|
|
|
+ isLoadingProducts = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ purchaseError = nil
|
|
|
+ do {
|
|
|
+ let loaded = try await loadProductsWithTimeout(seconds: 12)
|
|
|
+ var map: [String: Product] = [:]
|
|
|
+ for product in loaded {
|
|
|
+ map[product.id] = product
|
|
|
+ }
|
|
|
+ productsByID = map
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ } catch is ProductLoadError {
|
|
|
+ purchaseError = .productsLoadTimeout
|
|
|
+ } catch {
|
|
|
+ purchaseError = .generic
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
|
|
|
+ try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
|
+ group.addTask {
|
|
|
+ try await Product.products(for: SubscriptionProductID.all)
|
|
|
+ }
|
|
|
+ group.addTask {
|
|
|
+ let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
|
|
|
+ try await Task.sleep(nanoseconds: timeoutNanoseconds)
|
|
|
+ throw ProductLoadError.timeout
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let first = try await group.next() else {
|
|
|
+ throw ProductLoadError.timeout
|
|
|
+ }
|
|
|
+ group.cancelAll()
|
|
|
+ return first
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPremiumAccess() async {
|
|
|
+ var hasPremium = false
|
|
|
+
|
|
|
+ for await result in Transaction.currentEntitlements {
|
|
|
+ guard case .verified(let transaction) = result else { continue }
|
|
|
+ guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
|
+ guard transaction.revocationDate == nil else { continue }
|
|
|
+
|
|
|
+ if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ hasPremium = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ if !hasPremium {
|
|
|
+ hasPremium = await hasActiveSubscriptionStatus()
|
|
|
+ }
|
|
|
+
|
|
|
+ hasPremiumAccess = hasPremium
|
|
|
+ }
|
|
|
+
|
|
|
+ private func hasActiveSubscriptionStatus() async -> Bool {
|
|
|
+ if productsByID.isEmpty {
|
|
|
+ do {
|
|
|
+ let loaded = try await Product.products(for: SubscriptionProductID.all)
|
|
|
+ var map: [String: Product] = [:]
|
|
|
+ for product in loaded { map[product.id] = product }
|
|
|
+ productsByID = map
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for product in productsByID.values {
|
|
|
+ guard let subscription = product.subscription else { continue }
|
|
|
+ do {
|
|
|
+ let statuses = try await subscription.status
|
|
|
+ let isActive = statuses.contains { status in
|
|
|
+ switch status.state {
|
|
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
+ return true
|
|
|
+ case .expired, .revoked:
|
|
|
+ return false
|
|
|
+ default:
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if isActive { return true }
|
|
|
+ } catch {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func listenForTransactionUpdates() async {
|
|
|
+ for await update in Transaction.updates {
|
|
|
+ guard case .verified(let transaction) = update else { continue }
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshPremiumAccess()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
|
|
|
+ switch period.unit {
|
|
|
+ case .day where period.value == 1: "1 Day"
|
|
|
+ case .day: "\(period.value) Days"
|
|
|
+ case .week where period.value == 1: "1 Week"
|
|
|
+ case .week: "\(period.value) Weeks"
|
|
|
+ case .month where period.value == 1: "1 Month"
|
|
|
+ case .month: "\(period.value) Months"
|
|
|
+ case .year where period.value == 1: "1 Year"
|
|
|
+ case .year: "\(period.value) Years"
|
|
|
+ @unknown default: "\(period.value) Days"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
|
|
|
+ switch period.unit {
|
|
|
+ case .day: period.value == 1 ? "day" : "\(period.value) days"
|
|
|
+ case .week: period.value == 1 ? "week" : "\(period.value) weeks"
|
|
|
+ case .month: period.value == 1 ? "month" : "\(period.value) months"
|
|
|
+ case .year: period.value == 1 ? "year" : "\(period.value) years"
|
|
|
+ @unknown default: "billing period"
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|