|
@@ -31,6 +31,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
@Published private(set) var productsByID: [String: Product] = [:]
|
|
@Published private(set) var productsByID: [String: Product] = [:]
|
|
|
@Published private(set) var isLoadingProducts = false
|
|
@Published private(set) var isLoadingProducts = false
|
|
|
|
|
+ @Published private(set) var availablePaywallPlans: [PaywallPlan] = []
|
|
|
@Published private(set) var purchasingPlan: PaywallPlan?
|
|
@Published private(set) var purchasingPlan: PaywallPlan?
|
|
|
@Published private(set) var isRestoringPurchases = false
|
|
@Published private(set) var isRestoringPurchases = false
|
|
|
@Published private(set) var hasPremiumAccess = false
|
|
@Published private(set) var hasPremiumAccess = false
|
|
@@ -41,24 +42,21 @@ final class SubscriptionManager: ObservableObject {
|
|
|
@Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
|
|
@Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
|
|
|
@Published var purchaseError: PurchaseError?
|
|
@Published var purchaseError: PurchaseError?
|
|
|
|
|
|
|
|
- var availablePaywallPlans: [PaywallPlan] {
|
|
|
|
|
- PaywallPlan.allCases
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
var isResolvingEntitlements: Bool {
|
|
var isResolvingEntitlements: Bool {
|
|
|
!hasResolvedPremiumStatus
|
|
!hasResolvedPremiumStatus
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var hasLifetimeAccess: Bool {
|
|
var hasLifetimeAccess: Bool {
|
|
|
- activePremiumPlan == .lifetime
|
|
|
|
|
|
|
+ activePremiumPlan?.isLifetime == true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var hasActiveSubscription: Bool {
|
|
var hasActiveSubscription: Bool {
|
|
|
- activePremiumPlan == .monthly || activePremiumPlan == .yearly
|
|
|
|
|
|
|
+ guard let activePremiumPlan else { return false }
|
|
|
|
|
+ return !activePremiumPlan.isLifetime
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var hasAllProductsLoaded: Bool {
|
|
var hasAllProductsLoaded: Bool {
|
|
|
- SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
|
|
|
|
|
|
|
+ !availablePaywallPlans.isEmpty
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
var isEligibleForFreeAITier: Bool {
|
|
var isEligibleForFreeAITier: Bool {
|
|
@@ -88,7 +86,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
launchedWithCachedPremium = true
|
|
launchedWithCachedPremium = true
|
|
|
hasPremiumAccess = true
|
|
hasPremiumAccess = true
|
|
|
if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
|
|
|
- let plan = PaywallPlan(rawValue: planRaw) {
|
|
|
|
|
|
|
+ let plan = PaywallPlan.from(rawValue: planRaw) {
|
|
|
activePremiumPlan = plan
|
|
activePremiumPlan = plan
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -178,7 +176,9 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func priceSuffix(for plan: PaywallPlan) -> String? {
|
|
func priceSuffix(for plan: PaywallPlan) -> String? {
|
|
|
- plan.planCopy(from: PaywallConfigService.shared.config).priceSuffix
|
|
|
|
|
|
|
+ let config = PaywallConfigService.shared.config
|
|
|
|
|
+ let product = product(for: plan)
|
|
|
|
|
+ return plan.planCopy(from: config, product: product).priceSuffix
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func billingDescription(for plan: PaywallPlan) -> String {
|
|
func billingDescription(for plan: PaywallPlan) -> String {
|
|
@@ -187,20 +187,20 @@ final class SubscriptionManager: ObservableObject {
|
|
|
return config.loadingDisclosure
|
|
return config.loadingDisclosure
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- switch plan {
|
|
|
|
|
- case .monthly:
|
|
|
|
|
- return "Billed at \(product.displayPrice) every month"
|
|
|
|
|
- case .yearly:
|
|
|
|
|
- return "Billed at \(product.displayPrice) every year"
|
|
|
|
|
- case .lifetime:
|
|
|
|
|
|
|
+ if product.type == .nonConsumable {
|
|
|
return "One-time payment of \(product.displayPrice)"
|
|
return "One-time payment of \(product.displayPrice)"
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ let period = product.subscription.map {
|
|
|
|
|
+ config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
|
|
|
|
|
+ } ?? config.messages.periodFallback
|
|
|
|
|
+ return "Billed at \(product.displayPrice) every \(period)"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
func purchase(_ plan: PaywallPlan) async -> Bool {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
|
|
|
|
|
- guard availablePaywallPlans.contains(plan) else {
|
|
|
|
|
|
|
+ guard availablePaywallPlans.contains(where: { $0 == plan }) else {
|
|
|
purchaseError = .planUnavailable
|
|
purchaseError = .planUnavailable
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
@@ -212,21 +212,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
guard let product else {
|
|
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
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ purchaseError = .planUnavailable
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -292,7 +278,9 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
private func performLoadProducts(presentLoadingUI: Bool?) async {
|
|
|
- let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
|
|
|
|
|
|
|
+ let config = PaywallConfigService.shared.config
|
|
|
|
|
+ let requestedProductIDs = Set(SubscriptionProductID.productIDs(from: config))
|
|
|
|
|
+ let missingAny = availablePaywallPlans.isEmpty
|
|
|
let shouldShowLoading = presentLoadingUI ?? missingAny
|
|
let shouldShowLoading = presentLoadingUI ?? missingAny
|
|
|
|
|
|
|
|
if !missingAny, presentLoadingUI != true {
|
|
if !missingAny, presentLoadingUI != true {
|
|
@@ -314,10 +302,14 @@ final class SubscriptionManager: ObservableObject {
|
|
|
purchaseError = nil
|
|
purchaseError = nil
|
|
|
productLoadError = nil
|
|
productLoadError = nil
|
|
|
do {
|
|
do {
|
|
|
- let loaded = try await loadProductsWithTimeout(seconds: 12)
|
|
|
|
|
|
|
+ let loaded = try await loadProductsWithTimeout(
|
|
|
|
|
+ productIDs: requestedProductIDs,
|
|
|
|
|
+ seconds: 12
|
|
|
|
|
+ )
|
|
|
guard !loaded.isEmpty else {
|
|
guard !loaded.isEmpty else {
|
|
|
productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
|
|
productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
|
|
|
productsByID = [:]
|
|
productsByID = [:]
|
|
|
|
|
+ availablePaywallPlans = []
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
var map: [String: Product] = [:]
|
|
var map: [String: Product] = [:]
|
|
@@ -325,6 +317,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
map[product.id] = product
|
|
map[product.id] = product
|
|
|
}
|
|
}
|
|
|
productsByID = map
|
|
productsByID = map
|
|
|
|
|
+ updateAvailablePaywallPlans(from: map, requestedProductIDs: requestedProductIDs)
|
|
|
productLoadError = nil
|
|
productLoadError = nil
|
|
|
await refreshPremiumAccess()
|
|
await refreshPremiumAccess()
|
|
|
await refreshTrialOffers()
|
|
await refreshTrialOffers()
|
|
@@ -337,10 +330,21 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
|
|
|
|
|
|
|
+ private func updateAvailablePaywallPlans(
|
|
|
|
|
+ from productsByID: [String: Product],
|
|
|
|
|
+ requestedProductIDs: Set<String>
|
|
|
|
|
+ ) {
|
|
|
|
|
+ availablePaywallPlans = requestedProductIDs
|
|
|
|
|
+ .compactMap { productID in
|
|
|
|
|
+ productsByID[productID].map { _ in PaywallPlan(productID: productID) }
|
|
|
|
|
+ }
|
|
|
|
|
+ .sorted(by: PaywallPlan.sortOrder)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadProductsWithTimeout(productIDs: Set<String>, seconds: TimeInterval) async throws -> [Product] {
|
|
|
try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
|
group.addTask {
|
|
group.addTask {
|
|
|
- try await Product.products(for: SubscriptionProductID.all)
|
|
|
|
|
|
|
+ try await Product.products(for: productIDs)
|
|
|
}
|
|
}
|
|
|
group.addTask {
|
|
group.addTask {
|
|
|
let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
|
|
let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
|
|
@@ -371,7 +375,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if resolvedPlan == nil || resolvedPlan != .lifetime {
|
|
|
|
|
|
|
+ if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
|
|
|
if let subscriptionPlan = await activeSubscriptionPlan() {
|
|
if let subscriptionPlan = await activeSubscriptionPlan() {
|
|
|
resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
|
|
resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
|
|
|
}
|
|
}
|
|
@@ -501,7 +505,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
for await result in Transaction.all {
|
|
for await result in Transaction.all {
|
|
|
guard case .verified(let transaction) = result else { continue }
|
|
guard case .verified(let transaction) = result else { continue }
|
|
|
- guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
|
|
|
|
|
|
|
+ guard SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config).contains(transaction.productID) else { continue }
|
|
|
|
|
|
|
|
foundPurchase = true
|
|
foundPurchase = true
|
|
|
if !hasEverPurchasedPremium {
|
|
if !hasEverPurchasedPremium {
|
|
@@ -539,7 +543,8 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
|
|
private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
|
|
|
let config = PaywallConfigService.shared.config
|
|
let config = PaywallConfigService.shared.config
|
|
|
- guard plan == config.trialEligiblePlan else { return nil }
|
|
|
|
|
|
|
+ guard let eligiblePlan = config.trialEligiblePlan,
|
|
|
|
|
+ plan.productID == eligiblePlan.productID else { return nil }
|
|
|
guard let fallback = config.trial.fallbackDuration else { return nil }
|
|
guard let fallback = config.trial.fallbackDuration else { return nil }
|
|
|
|
|
|
|
|
if let product = product(for: plan),
|
|
if let product = product(for: plan),
|
|
@@ -554,8 +559,8 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
private func persistPremiumCache() {
|
|
private func persistPremiumCache() {
|
|
|
UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
|
|
UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
|
|
|
- if let plan = activePremiumPlan?.rawValue {
|
|
|
|
|
- UserDefaults.standard.set(plan, forKey: Self.cachedPremiumPlanKey)
|
|
|
|
|
|
|
+ if let plan = activePremiumPlan {
|
|
|
|
|
+ UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
|
|
|
} else {
|
|
} else {
|
|
|
UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
|
|
UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
|
|
|
}
|
|
}
|
|
@@ -567,7 +572,8 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
|
|
private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
- guard SubscriptionProductID.all.contains(transaction.productID) else { return false }
|
|
|
|
|
|
|
+ let knownProductIDs = Set(SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config))
|
|
|
|
|
+ guard knownProductIDs.contains(transaction.productID) else { return false }
|
|
|
guard transaction.revocationDate == nil else { return false }
|
|
guard transaction.revocationDate == nil else { return false }
|
|
|
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
|
return false
|
|
return false
|
|
@@ -576,22 +582,26 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
|
|
private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
|
|
|
- if current == .lifetime || candidate == .lifetime {
|
|
|
|
|
- return .lifetime
|
|
|
|
|
- }
|
|
|
|
|
- if current == .yearly || candidate == .yearly {
|
|
|
|
|
- return .yearly
|
|
|
|
|
- }
|
|
|
|
|
- if current == .monthly || candidate == .monthly {
|
|
|
|
|
- return .monthly
|
|
|
|
|
|
|
+ let order = [
|
|
|
|
|
+ SubscriptionProductID.lifetime,
|
|
|
|
|
+ SubscriptionProductID.yearly,
|
|
|
|
|
+ SubscriptionProductID.monthly,
|
|
|
|
|
+ SubscriptionProductID.weekly,
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ func rank(for plan: PaywallPlan) -> Int {
|
|
|
|
|
+ order.firstIndex(of: plan.productID) ?? Int.max
|
|
|
}
|
|
}
|
|
|
- return candidate
|
|
|
|
|
|
|
+
|
|
|
|
|
+ guard let current else { return candidate }
|
|
|
|
|
+ return rank(for: candidate) < rank(for: current) ? candidate : current
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func activePlanFromLatestTransactions() async -> PaywallPlan? {
|
|
private func activePlanFromLatestTransactions() async -> PaywallPlan? {
|
|
|
var resolvedPlan: PaywallPlan?
|
|
var resolvedPlan: PaywallPlan?
|
|
|
|
|
+ let productIDs = SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config)
|
|
|
|
|
|
|
|
- for productID in SubscriptionProductID.all {
|
|
|
|
|
|
|
+ for productID in productIDs {
|
|
|
guard let result = await Transaction.latest(for: productID) else { continue }
|
|
guard let result = await Transaction.latest(for: productID) else { continue }
|
|
|
guard case .verified(let transaction) = result else { continue }
|
|
guard case .verified(let transaction) = result else { continue }
|
|
|
guard let plan = planGrantingPremium(from: transaction) else { continue }
|
|
guard let plan = planGrantingPremium(from: transaction) else { continue }
|
|
@@ -614,7 +624,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
|
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
for await result in Transaction.currentEntitlements {
|
|
|
guard case .verified(let transaction) = result else { continue }
|
|
guard case .verified(let transaction) = result else { continue }
|
|
|
- guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
|
|
|
|
|
|
|
+ guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
|
|
|
resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
resolvedPlan = preferredPlan(resolvedPlan, plan)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -648,6 +658,7 @@ final class SubscriptionManager: ObservableObject {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
|
|
private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
- SubscriptionProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
|
|
|
|
|
|
|
+ let knownProductIDs = Set(SubscriptionProductID.productIDs(from: PaywallConfigService.shared.config))
|
|
|
|
|
+ return knownProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|