├── .gitignore ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources └── SwiftfulPurchasing │ ├── Extensions │ ├── Array+EXT.swift │ └── Error+EXT.swift │ ├── Models │ ├── AnyProduct+StoreKit.swift │ ├── AnyProduct.swift │ ├── EntitlementOwnershipOption.swift │ ├── PurchaseLogger.swift │ ├── PurchaseProfileAttributes.swift │ └── PurchasedEntitlement.swift │ ├── PurchaseManager.swift │ └── Services │ ├── MockPurchaseService.swift │ ├── PurchaseService.swift │ └── StoreKitPurchaseService.swift └── Tests └── SwiftfulPurchasingTests └── PurchaseManager+Tests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Swiftful Thinking, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftfulPurchasing", 8 | platforms: [ 9 | .iOS(.v17), 10 | .macOS(.v14) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "SwiftfulPurchasing", 16 | targets: ["SwiftfulPurchasing"]), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "SwiftfulPurchasing" 23 | ), 24 | .testTarget( 25 | name: "SwiftfulPurchasingTests", 26 | dependencies: ["SwiftfulPurchasing"] 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### 🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/offers/REyNLwwH 2 | 3 | # Purchase Manager for Swift 6 💰 4 | 5 | A reusable PurchaseManager for Swift applications, built for Swift 6. Includes `@Observable` support. 6 | 7 | Pre-built dependencies*: 8 | 9 | - Mock: Included 10 | - StoreKit: Included 11 | - RevenueCat: https://github.com/SwiftfulThinking/SwiftfulPurchasingRevenueCat.git 12 | 13 | \* Created another? Send the url in [issues](https://github.com/SwiftfulThinking/SwiftfulPurchasing/issues)! 🥳 14 | 15 | ## Setup 16 | 17 |
18 | Details (Click to expand) 19 |
20 | 21 | #### Create an instance of PurchaseManager: 22 | 23 | ```swift 24 | let purchaseManager = PurchaseManager(services: any PurchaseService, logger: LogManager?) 25 | 26 | #if DEBUG 27 | let purchaseManager = PurchaseManager(service: MockPurchaseService(), logger: logManager) 28 | #else 29 | let purchaseManager = PurchaseManager(service: StoreKitPurchaseService(), logger: logManager) 30 | #endif 31 | ``` 32 | 33 | #### Optionally add to SwiftUI environment as an @Observable 34 | 35 | ```swift 36 | Text("Hello, world!") 37 | .environment(purchaseManager) 38 | ``` 39 | 40 |
41 | 42 | ## Inject dependencies 43 | 44 |
45 | Details (Click to expand) 46 |
47 | 48 | `PurchaseManager` is initialized with a `PurchaseService`. This is a public protocol you can use to create your own dependency. 49 | 50 | 'StoreKitPurchaseService` is included within the package, which uses the StoreKit framework to manage purchases. 51 | ```swift 52 | let productIds = ["product.id.yearly", "product.id.monthly"] 53 | let storeKit = StoreKitPurchaseService(productIds: productIds) 54 | let logger = PurchaseManager(services: storeKit) 55 | ``` 56 | 57 | `MockPurchaseService` is also included for SwiftUI previews and testing. 58 | 59 | ```swift 60 | // No activeEntitlements = the user has not purchased 61 | let service = MockPurchaseService(activeEntitlements: [], availableProducts: AnyProduct.mocks) 62 | 63 | // Yes activeEntitlements = the user has purchased 64 | let service = MockPurchaseService(activeEntitlements: [PurchasedEntitlement.mock], availableProducts: AnyProduct.mocks) 65 | ``` 66 | 67 | Other services are not directly included, so that the developer can pick-and-choose which dependencies to add to the project. 68 | 69 | You can create your own `PurchaseService` by conforming to the protocol: 70 | 71 | ```swift 72 | public protocol PurchaseService: Sendable { 73 | func getAvailableProducts() async throws -> [AnyProduct] 74 | func getUserEntitlements() async throws -> [PurchasedEntitlement] 75 | func purchaseProduct(productId: String) async throws -> [PurchasedEntitlement] 76 | func restorePurchase() async throws -> [PurchasedEntitlement] 77 | func listenForTransactions(onTransactionsUpdated: @escaping @Sendable () async -> Void) async 78 | func logIn(userId: String, email: String?) async throws -> [PurchasedEntitlement] 79 | } 80 | ``` 81 | 82 |
83 | 84 | ## Manage user account 85 | 86 |
87 | Details (Click to expand) 88 |
89 | 90 | The manager will automatically fetch and listen for purchased entitlements on launch. 91 | 92 | Call `logIn` when the userId is set or changes. 93 | 94 | You can call `logIn` every app launch. 95 | 96 | ```swift 97 | purchaseManager.logIn(userId: String, email: String?) async throws 98 | purchaseManager.logOut() async throws 99 | ``` 100 | 101 |
102 | 103 | ## Manage purchases 104 | 105 |
106 | Details (Click to expand) 107 |
108 | 109 | #### Get user's entitlements: 110 | 111 | ```swift 112 | purchaseManager.entitlements // all purchased entitlements 113 | purchaseManager.entitlements.active // all purchased entitlements that are still active 114 | purchaseManager.entitlements.hasActiveEntitlement // user has at least 1 active entitlement 115 | ``` 116 | 117 | #### Make new purchase: 118 | 119 | ```swift 120 | // Products available for purchase to this user 121 | let products = try await purchaseManager.getAvailableProducts() 122 | 123 | // Purchase a specific product 124 | let entitlements = try await purchaseManager.purchaseProduct(productId: "") 125 | 126 | // Restore purchases 127 | let entitlements = try await restorePurchase() 128 | ``` 129 | 130 |
131 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Extensions/Array+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import Foundation 8 | 9 | extension Array { 10 | 11 | mutating func sortByKeyPath(_ keyPath: KeyPath, ascending: Bool = true) { 12 | self.sort { item1, item2 in 13 | let value1 = item1[keyPath: keyPath] 14 | let value2 = item2[keyPath: keyPath] 15 | return ascending ? (value1 < value2): (value1 > value2) 16 | } 17 | } 18 | 19 | func sortedByKeyPath(_ keyPath: KeyPath, ascending: Bool = true) -> [Element] { 20 | self.sorted { item1, item2 in 21 | let value1 = item1[keyPath: keyPath] 22 | let value2 = item2[keyPath: keyPath] 23 | return ascending ? (value1 < value2): (value1 > value2) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Extensions/Error+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Error+EXT.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import Foundation 8 | 9 | extension Error { 10 | var eventParameters: [String: Any] { 11 | [ 12 | "error_description": self.localizedDescription 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/AnyProduct+StoreKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyProduct+StoreKit.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import StoreKit 8 | 9 | public extension AnyProduct { 10 | 11 | init(storeKitProduct product: StoreKit.Product) { 12 | self.init( 13 | id: product.id, 14 | title: product.displayName, 15 | subtitle: product.description, 16 | priceString: product.displayPrice, 17 | productDuration: ProductDurationOption(unit: product.subscription?.subscriptionPeriod.unit) 18 | ) 19 | } 20 | 21 | } 22 | 23 | extension ProductDurationOption { 24 | 25 | init?(unit: Product.SubscriptionPeriod.Unit?) { 26 | if let unit { 27 | switch unit { 28 | case .day: 29 | self = .day 30 | case .week: 31 | self = .week 32 | case .month: 33 | self = .month 34 | case .year: 35 | self = .year 36 | default: 37 | return nil 38 | } 39 | } else { 40 | return nil 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/AnyProduct.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyProduct.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import SwiftUI 8 | 9 | public struct AnyProduct: Identifiable, Codable, Sendable { 10 | public let id: String 11 | public let title: String 12 | public let subtitle: String 13 | public let priceString: String 14 | public let productDuration: ProductDurationOption? 15 | 16 | public init( 17 | id: String, 18 | title: String, 19 | subtitle: String, 20 | priceString: String, 21 | productDuration: ProductDurationOption? 22 | ) { 23 | self.id = id 24 | self.title = title 25 | self.subtitle = subtitle 26 | self.priceString = priceString 27 | self.productDuration = productDuration 28 | } 29 | 30 | public var priceStringWithDuration: String { 31 | if let productDuration { 32 | return "\(priceString) / \(productDuration.rawValue)" 33 | } else { 34 | return "\(priceString)" 35 | } 36 | } 37 | 38 | public enum CodingKeys: String, CodingKey { 39 | case id 40 | case title 41 | case subtitle 42 | case priceString = "price_string" 43 | case productDuration = "product_duration" 44 | } 45 | 46 | public var eventParameters: [String: Any] { 47 | let dict: [String: Any?] = [ 48 | "product_\(CodingKeys.id.rawValue)": id, 49 | "product_\(CodingKeys.title.rawValue)": title, 50 | "product_\(CodingKeys.subtitle.rawValue)": subtitle, 51 | "product_\(CodingKeys.priceString.rawValue)": priceString, 52 | "product_\(CodingKeys.productDuration.rawValue)": productDuration?.rawValue 53 | ] 54 | return dict.compactMapValues({ $0 }) 55 | } 56 | 57 | public static let mockYearly: AnyProduct = AnyProduct( 58 | id: "mock.yearly.id", 59 | title: "Yearly subscription", 60 | subtitle: "This is a yearly subscription description.", 61 | priceString: "$99/year", 62 | productDuration: .year 63 | ) 64 | public static let mockMonthly: AnyProduct = AnyProduct( 65 | id: "mock.monthly.id", 66 | title: "Monthly subscription", 67 | subtitle: "This is a monthly subscription description.", 68 | priceString: "$10/month", 69 | productDuration: .month 70 | ) 71 | 72 | public static var mocks: [AnyProduct] { 73 | [mockYearly, mockMonthly] 74 | } 75 | } 76 | 77 | extension Array where Element == AnyProduct { 78 | 79 | public var eventParameters: [String: Any] { 80 | var dict: [String: Any?] = [ 81 | "products_count" : self.count, 82 | "products_ids" : self.compactMap({ $0.id }).sorted().joined(separator: ", "), 83 | "products_titles" : self.compactMap({ $0.title }).sorted().joined(separator: ", "), 84 | ] 85 | for product in self { 86 | for (key, value) in product.eventParameters { 87 | let uniqueKey = "\(key)_\(product.id)" 88 | dict[uniqueKey] = value 89 | } 90 | } 91 | return dict.compactMapValues({ $0 }) 92 | } 93 | } 94 | 95 | public enum ProductDurationOption: String, Codable, Sendable { 96 | case year, month, week, day 97 | } 98 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/EntitlementOwnershipOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntitlementOwnershipOption.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | 8 | public enum EntitlementOwnershipOption: Codable, Sendable { 9 | case purchased, familyShared, unknown 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/PurchaseLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseLogger.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 11/11/24. 6 | // 7 | @MainActor 8 | public protocol PurchaseLogger { 9 | func trackEvent(event: PurchaseLogEvent) 10 | func addUserProperties(dict: [String: Any], isHighPriority: Bool) 11 | } 12 | 13 | public protocol PurchaseLogEvent { 14 | var eventName: String { get } 15 | var parameters: [String: Any]? { get } 16 | var type: PurchaseLogType { get } 17 | } 18 | 19 | public enum PurchaseLogType: Int, CaseIterable, Sendable { 20 | case info // 0 21 | case analytic // 1 22 | case warning // 2 23 | case severe // 3 24 | 25 | var emoji: String { 26 | switch self { 27 | case .info: 28 | return "👋" 29 | case .analytic: 30 | return "📈" 31 | case .warning: 32 | return "⚠️" 33 | case .severe: 34 | return "🚨" 35 | } 36 | } 37 | 38 | var asString: String { 39 | switch self { 40 | case .info: return "info" 41 | case .analytic: return "analytic" 42 | case .warning: return "warning" 43 | case .severe: return "severe" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/PurchaseProfileAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseProfileAttributes.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 11/1/24. 6 | // 7 | import Foundation 8 | 9 | public struct PurchaseProfileAttributes: Sendable { 10 | // User Profile attributes 11 | public let email: String? 12 | public let phoneNumber: String? 13 | public let displayName: String? 14 | public let pushToken: String? 15 | 16 | // Dependencies & Integration attributes 17 | public let adjustId: String? 18 | public let appsFlyerId: String? 19 | public let facebookAnonymousId: String? 20 | public let mParticleId: String? 21 | public let oneSignalId: String? 22 | public let airshipChannelId: String? 23 | public let cleverAppId: String? 24 | public let kochavaDeviceId: String? 25 | public let mixpanelDistinctId: String? 26 | public let firebaseAppInstanceId: String? 27 | public let brazeAliasName: String? 28 | public let brazeAliasLabel: String? 29 | 30 | // Install attributes 31 | 32 | /// Install campaign for the user (ie. utm_source=facebook&utm_campaign=spring_sale) 33 | public let installMediaSource: String? 34 | 35 | /// Install ad group for the user 36 | public let installAdGroup: String? 37 | 38 | /// Install ad for the user 39 | public let installAd: String? 40 | 41 | /// Install keyword for the user 42 | public let installKeyword: String? 43 | 44 | /// Install ad creative for the user 45 | public let installCreative: String? 46 | 47 | public init( 48 | email: String? = nil, 49 | phoneNumber: String? = nil, 50 | displayName: String? = nil, 51 | pushToken: String? = nil, 52 | adjustId: String? = nil, 53 | appsFlyerId: String? = nil, 54 | facebookAnonymousId: String? = nil, 55 | mParticleId: String? = nil, 56 | oneSignalId: String? = nil, 57 | airshipChannelId: String? = nil, 58 | cleverAppId: String? = nil, 59 | kochavaDeviceId: String? = nil, 60 | mixpanelDistinctId: String? = nil, 61 | firebaseAppInstanceId: String? = nil, 62 | brazeAliasName: String? = nil, 63 | brazeAliasLabel: String? = nil, 64 | installMediaSource: String? = nil, 65 | installAdGroup: String? = nil, 66 | installAd: String? = nil, 67 | installKeyword: String? = nil, 68 | installCreative: String? = nil 69 | ) { 70 | self.email = email 71 | self.phoneNumber = phoneNumber 72 | self.displayName = displayName 73 | self.pushToken = pushToken 74 | self.adjustId = adjustId 75 | self.appsFlyerId = appsFlyerId 76 | self.facebookAnonymousId = facebookAnonymousId 77 | self.mParticleId = mParticleId 78 | self.oneSignalId = oneSignalId 79 | self.airshipChannelId = airshipChannelId 80 | self.cleverAppId = cleverAppId 81 | self.kochavaDeviceId = kochavaDeviceId 82 | self.mixpanelDistinctId = mixpanelDistinctId 83 | self.firebaseAppInstanceId = firebaseAppInstanceId 84 | self.brazeAliasName = brazeAliasName 85 | self.brazeAliasLabel = brazeAliasLabel 86 | self.installMediaSource = installMediaSource 87 | self.installAdGroup = installAdGroup 88 | self.installAd = installAd 89 | self.installKeyword = installKeyword 90 | self.installCreative = installCreative 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Models/PurchasedEntitlement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchasedEntitlement.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | 10 | public struct PurchasedEntitlement: Codable, Sendable { 11 | 12 | // For StoreKit, this is the transaction ID 13 | // For RevenueCat, this is a unique ID they provide (they abstract away the transaction) 14 | public let id: String 15 | public let productId: String 16 | public let expirationDate: Date? 17 | public let isActive: Bool 18 | public let originalPurchaseDate: Date? 19 | public let latestPurchaseDate: Date? 20 | public let ownershipType: EntitlementOwnershipOption 21 | public let isSandbox: Bool 22 | public let isVerified: Bool 23 | 24 | public init(id: String, productId: String, expirationDate: Date?, isActive: Bool, originalPurchaseDate: Date?, latestPurchaseDate: Date?, ownershipType: EntitlementOwnershipOption, isSandbox: Bool, isVerified: Bool) { 25 | self.id = id 26 | self.productId = productId 27 | self.expirationDate = expirationDate 28 | self.isActive = isActive 29 | self.originalPurchaseDate = originalPurchaseDate 30 | self.latestPurchaseDate = latestPurchaseDate 31 | self.ownershipType = ownershipType 32 | self.isSandbox = isSandbox 33 | self.isVerified = isVerified 34 | } 35 | 36 | public var expirationDateCalc: Date { 37 | expirationDate ?? .distantPast 38 | } 39 | 40 | public static let mock: PurchasedEntitlement = PurchasedEntitlement( 41 | id: UUID().uuidString, 42 | productId: "my.product.id", 43 | expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60), 44 | isActive: true, 45 | originalPurchaseDate: .now, 46 | latestPurchaseDate: .now, 47 | ownershipType: .purchased, 48 | isSandbox: true, 49 | isVerified: true 50 | ) 51 | 52 | public enum CodingKeys: String, CodingKey { 53 | case id 54 | case productId = "product_id" 55 | case expirationDate = "expiration_date" 56 | case isActive = "is_active" 57 | case originalPurchaseDate = "original_purchase_date" 58 | case latestPurchaseDate = "latest_purchase_date" 59 | case ownershipType = "ownership_type" 60 | case isSandbox = "is_sandbox" 61 | case isVerified = "is_verified" 62 | } 63 | 64 | public var eventParameters: [String: Any] { 65 | let dict: [String: Any?] = [ 66 | "entitlement_\(CodingKeys.id.rawValue)": id, 67 | "entitlement_\(CodingKeys.productId.rawValue)": productId, 68 | "entitlement_\(CodingKeys.expirationDate.rawValue)": expirationDate, 69 | "entitlement_\(CodingKeys.isActive.rawValue)": isActive, 70 | "entitlement_\(CodingKeys.originalPurchaseDate.rawValue)": originalPurchaseDate, 71 | "entitlement_\(CodingKeys.latestPurchaseDate.rawValue)": latestPurchaseDate, 72 | "entitlement_\(CodingKeys.ownershipType.rawValue)": ownershipType, 73 | "entitlement_\(CodingKeys.isSandbox.rawValue)": isSandbox, 74 | "entitlement_\(CodingKeys.isVerified.rawValue)": isVerified 75 | ] 76 | return dict.compactMapValues({ $0 }) 77 | } 78 | } 79 | 80 | extension Array where Element == PurchasedEntitlement { 81 | 82 | public var eventParameters: [String: Any] { 83 | let activeEntitlements = self.active 84 | var dict: [String: Any?] = [ 85 | "entitlements_count_all" : count, 86 | "entitlements_count_active" : activeEntitlements.count, 87 | "entitlements_ids_all" : compactMap({ $0.id }).sorted().joined(separator: ", "), 88 | "entitlements_ids_active" : activeEntitlements.compactMap({ $0.id }).sorted().joined(separator: ", "), 89 | "entitlements_product_ids_all" : compactMap({ $0.productId }).sorted().joined(separator: ", "), 90 | "entitlements_product_ids_active" : activeEntitlements.compactMap({ $0.productId }).sorted().joined(separator: ", "), 91 | "has_active_entitlement" : hasActiveEntitlement 92 | ] 93 | for product in self { 94 | for (key, value) in product.eventParameters { 95 | let uniqueKey = "\(key)_\(product.productId)" 96 | dict[uniqueKey] = value 97 | } 98 | } 99 | return dict.compactMapValues({ $0 }) 100 | } 101 | } 102 | 103 | public extension Array where Element == PurchasedEntitlement { 104 | 105 | /// All active entitlements 106 | var active: [PurchasedEntitlement] { 107 | self.filter({ $0.isActive }) 108 | } 109 | 110 | /// TRUE if the user has at least one active entitlement 111 | var hasActiveEntitlement: Bool { 112 | !active.isEmpty 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/PurchaseManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseManager.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import SwiftUI 8 | 9 | @MainActor 10 | @Observable 11 | public class PurchaseManager { 12 | private let logger: PurchaseLogger? 13 | private let service: PurchaseService 14 | 15 | /// User's purchased entitlements. 16 | public private(set) var entitlements: [PurchasedEntitlement] = [] 17 | private var listener: Task? 18 | 19 | public init(service: PurchaseService, logger: PurchaseLogger? = nil) { 20 | self.service = service 21 | self.logger = logger 22 | self.configure() 23 | } 24 | 25 | private func configure() { 26 | Task { 27 | // Manually fetch, in case the listener doesn't work 28 | if let entitlements = try? await service.getUserEntitlements() { 29 | self.updateActiveEntitlements(entitlements: entitlements) 30 | } 31 | } 32 | 33 | // Add listener 34 | listener?.cancel() 35 | listener = Task { 36 | await service.listenForTransactions(onTransactionsUpdated: { entitlements in 37 | await self.updateActiveEntitlements(entitlements: entitlements) 38 | }) 39 | } 40 | } 41 | 42 | private func updateActiveEntitlements(entitlements: [PurchasedEntitlement]) { 43 | self.entitlements = entitlements.sortedByKeyPath(\.expirationDateCalc, ascending: false) 44 | 45 | // Log event 46 | logger?.trackEvent(event: Event.entitlementsSuccess(entitlements: entitlements)) 47 | 48 | // Log user properties relating to purchase 49 | logger?.addUserProperties(dict: entitlements.eventParameters, isHighPriority: false) 50 | } 51 | 52 | /// Return all available products to purchase 53 | public func getProducts(productIds: [String]) async throws -> [AnyProduct] { 54 | logger?.trackEvent(event: Event.getProductsStart) 55 | 56 | do { 57 | let products = try await service.getProducts(productIds: productIds) 58 | logger?.trackEvent(event: Event.getProductsSuccess(products: products)) 59 | return products 60 | } catch { 61 | logger?.trackEvent(event: Event.getProductsFail(error: error)) 62 | throw error 63 | } 64 | } 65 | 66 | /// Purchase product and return user's purchased entitlements 67 | @discardableResult 68 | public func purchaseProduct(productId: String) async throws -> [PurchasedEntitlement] { 69 | logger?.trackEvent(event: Event.purchaseStart(productId: productId)) 70 | 71 | do { 72 | entitlements = try await service.purchaseProduct(productId: productId) 73 | logger?.trackEvent(event: Event.purchaseSuccess(entitlements: entitlements)) 74 | updateActiveEntitlements(entitlements: entitlements) 75 | return entitlements 76 | } catch { 77 | logger?.trackEvent(event: Event.purchaseFail(error: error)) 78 | throw error 79 | } 80 | } 81 | 82 | public func checkTrialEligibility(productId: String) async throws -> Bool { 83 | try await service.checkTrialEligibility(productId: productId) 84 | } 85 | 86 | /// Restore purchase and return user's purchased entitlements 87 | @discardableResult 88 | public func restorePurchase() async throws -> [PurchasedEntitlement] { 89 | logger?.trackEvent(event: Event.restorePurchaseStart) 90 | 91 | do { 92 | entitlements = try await service.restorePurchase() 93 | logger?.trackEvent(event: Event.restorePurchaseSuccess(entitlements: entitlements)) 94 | updateActiveEntitlements(entitlements: entitlements) 95 | return entitlements 96 | } catch { 97 | logger?.trackEvent(event: Event.restorePurchaseFail(error: error)) 98 | throw error 99 | } 100 | } 101 | 102 | /// Log in to PurchaseService. Optionally include attributes for user profile. 103 | @discardableResult 104 | public func logIn(userId: String, userAttributes: PurchaseProfileAttributes? = nil) async throws -> [PurchasedEntitlement] { 105 | logger?.trackEvent(event: Event.loginStart) 106 | 107 | do { 108 | entitlements = try await service.logIn(userId: userId) 109 | logger?.trackEvent(event: Event.loginSuccess(entitlements: entitlements)) 110 | updateActiveEntitlements(entitlements: entitlements) 111 | 112 | if let userAttributes { 113 | try await updateProfileAttributes(attributes: userAttributes) 114 | } 115 | 116 | defer { 117 | configure() 118 | } 119 | 120 | return entitlements 121 | } catch { 122 | logger?.trackEvent(event: Event.loginFail(error: error)) 123 | throw error 124 | } 125 | } 126 | 127 | /// Update logged in user profile. 128 | public func updateProfileAttributes(attributes: PurchaseProfileAttributes) async throws { 129 | try await service.updateProfileAttributes(attributes: attributes) 130 | } 131 | 132 | /// Log out of PurchaseService. Will remove purchased entitlements in memory. Note: does not log user out of Apple ID account, 133 | public func logOut() async throws { 134 | do { 135 | try await service.logOut() 136 | listener?.cancel() 137 | entitlements.removeAll() 138 | 139 | defer { 140 | configure() 141 | } 142 | 143 | logger?.trackEvent(event: Event.logOutSuccess) 144 | } catch { 145 | logger?.trackEvent(event: Event.logOutFail(error: error)) 146 | throw error 147 | } 148 | } 149 | } 150 | 151 | extension PurchaseManager { 152 | enum Event: PurchaseLogEvent { 153 | case loginStart 154 | case loginSuccess(entitlements: [PurchasedEntitlement]) 155 | case loginFail(error: Error) 156 | case entitlementsSuccess(entitlements: [PurchasedEntitlement]) 157 | case purchaseStart(productId: String) 158 | case purchaseSuccess(entitlements: [PurchasedEntitlement]) 159 | case purchaseFail(error: Error) 160 | case restorePurchaseStart 161 | case restorePurchaseSuccess(entitlements: [PurchasedEntitlement]) 162 | case restorePurchaseFail(error: Error) 163 | case getProductsStart 164 | case getProductsSuccess(products: [AnyProduct]) 165 | case getProductsFail(error: Error) 166 | case logOutSuccess 167 | case logOutFail(error: Error) 168 | 169 | var eventName: String { 170 | switch self { 171 | case .loginStart: return "Purchasing_Login_Start" 172 | case .loginSuccess: return "Purchasing_Login_Success" 173 | case .loginFail: return "Purchasing_Login_Fail" 174 | case .entitlementsSuccess: return "Purchasing_Entitlements_Success" 175 | case .purchaseStart: return "Purchasing_Purchase_Start" 176 | case .purchaseSuccess: return "Purchasing_Purchase_Success" 177 | case .purchaseFail: return "Purchasing_Purchase_Fail" 178 | case .restorePurchaseStart: return "Purchasing_Restore_Start" 179 | case .restorePurchaseSuccess: return "Purchasing_Restore_Success" 180 | case .restorePurchaseFail: return "Purchasing_Restore_Fail" 181 | case .getProductsStart: return "Purchasing_GetProducts_Start" 182 | case .getProductsSuccess: return "Purchasing_GetProducts_Success" 183 | case .getProductsFail: return "Purchasing_GetProducts_Fail" 184 | case .logOutSuccess: return "Purchasing_Logout_Success" 185 | case .logOutFail: return "Purchasing_Logout_Fail" 186 | } 187 | } 188 | 189 | var parameters: [String: Any]? { 190 | switch self { 191 | case .loginSuccess(entitlements: let entitlements), .entitlementsSuccess(entitlements: let entitlements), .purchaseSuccess(entitlements: let entitlements), .restorePurchaseSuccess(entitlements: let entitlements): 192 | return entitlements.eventParameters 193 | case .getProductsSuccess(products: let products): 194 | return products.eventParameters 195 | case .purchaseStart(productId: let productId): 196 | return ["product_id": productId] 197 | case .loginFail(error: let error), .purchaseFail(error: let error), .restorePurchaseFail(error: let error), .getProductsFail(error: let error), .logOutFail(error: let error): 198 | return error.eventParameters 199 | default: 200 | return nil 201 | } 202 | } 203 | 204 | var type: PurchaseLogType { 205 | switch self { 206 | case .loginFail, .purchaseFail, .restorePurchaseFail, .getProductsFail, .logOutFail: 207 | return .severe 208 | default: 209 | return .info 210 | } 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Services/MockPurchaseService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPurchaseService.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/28/24. 6 | // 7 | import SwiftUI 8 | 9 | public actor MockPurchaseService: PurchaseService { 10 | 11 | var activeEntitlements: [PurchasedEntitlement] 12 | let availableProducts: [AnyProduct] 13 | 14 | public init(activeEntitlements: [PurchasedEntitlement] = [], availableProducts: [AnyProduct] = []) { 15 | self.activeEntitlements = activeEntitlements 16 | self.availableProducts = availableProducts 17 | } 18 | 19 | public func getProducts(productIds: [String]) async throws -> [AnyProduct] { 20 | availableProducts 21 | } 22 | 23 | public func getUserEntitlements() async throws -> [PurchasedEntitlement] { 24 | activeEntitlements 25 | } 26 | 27 | public func purchaseProduct(productId: String) async throws -> [PurchasedEntitlement] { 28 | try? await Task.sleep(for: .seconds(1)) 29 | let newProduct = PurchasedEntitlement( 30 | id: UUID().uuidString, 31 | productId: productId, 32 | expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60), 33 | isActive: true, 34 | originalPurchaseDate: .now, 35 | latestPurchaseDate: .now, 36 | ownershipType: .purchased, 37 | isSandbox: true, 38 | isVerified: true 39 | ) 40 | activeEntitlements.append(newProduct) 41 | return activeEntitlements 42 | } 43 | 44 | public func checkTrialEligibility(productId: String) async throws -> Bool { 45 | try? await Task.sleep(for: .seconds(1)) 46 | return true 47 | } 48 | 49 | public func restorePurchase() async throws -> [PurchasedEntitlement] { 50 | try? await Task.sleep(for: .seconds(1)) 51 | let newProduct = PurchasedEntitlement( 52 | id: UUID().uuidString, 53 | productId: UUID().uuidString, 54 | expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60), 55 | isActive: true, 56 | originalPurchaseDate: .now, 57 | latestPurchaseDate: .now, 58 | ownershipType: .purchased, 59 | isSandbox: true, 60 | isVerified: true 61 | ) 62 | activeEntitlements.append(newProduct) 63 | return activeEntitlements 64 | } 65 | 66 | public func listenForTransactions(onTransactionsUpdated: @escaping ([PurchasedEntitlement]) async -> Void) async { 67 | if let entitlements = try? await getUserEntitlements() { 68 | await onTransactionsUpdated(entitlements) 69 | } 70 | } 71 | 72 | public func logIn(userId: String) async throws -> [PurchasedEntitlement] { 73 | try? await Task.sleep(for: .seconds(1)) 74 | return activeEntitlements 75 | } 76 | 77 | public func updateProfileAttributes(attributes: PurchaseProfileAttributes) async throws { 78 | 79 | } 80 | 81 | public func logOut() async throws { 82 | activeEntitlements = [] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Services/PurchaseService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PurchaseService.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import SwiftUI 8 | 9 | public protocol PurchaseService: Sendable { 10 | func getProducts(productIds: [String]) async throws -> [AnyProduct] 11 | func getUserEntitlements() async throws -> [PurchasedEntitlement] 12 | func purchaseProduct(productId: String) async throws -> [PurchasedEntitlement] 13 | func checkTrialEligibility(productId: String) async throws -> Bool 14 | func restorePurchase() async throws -> [PurchasedEntitlement] 15 | func listenForTransactions(onTransactionsUpdated: @escaping @Sendable ([PurchasedEntitlement]) async -> Void) async 16 | func logIn(userId: String) async throws -> [PurchasedEntitlement] 17 | func updateProfileAttributes(attributes: PurchaseProfileAttributes) async throws 18 | func logOut() async throws 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftfulPurchasing/Services/StoreKitPurchaseService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreKitPurchaseService.swift 3 | // SwiftfulPurchasing 4 | // 5 | // Created by Nick Sarno on 9/27/24. 6 | // 7 | import Foundation 8 | import StoreKit 9 | 10 | public struct StoreKitPurchaseService: PurchaseService { 11 | 12 | public init() { 13 | 14 | } 15 | 16 | enum Error: LocalizedError { 17 | case productNotFound 18 | case userCancelledPurchase 19 | case failedToPurchase 20 | } 21 | 22 | public func getProducts(productIds: [String]) async throws -> [AnyProduct] { 23 | let products = try await Product.products(for: productIds) 24 | return products.map({ AnyProduct(storeKitProduct: $0) }) 25 | } 26 | 27 | public func listenForTransactions(onTransactionsUpdated: @escaping ([PurchasedEntitlement]) async -> Void) async { 28 | for await update in StoreKit.Transaction.updates { 29 | if let transaction = try? update.payloadValue { 30 | if let entitlements = try? await getUserEntitlements() { 31 | await onTransactionsUpdated(entitlements) 32 | } 33 | 34 | await transaction.finish() 35 | } 36 | } 37 | } 38 | 39 | public func purchaseProduct(productId: String) async throws -> [PurchasedEntitlement] { 40 | do { 41 | let products = try await Product.products(for: [productId]) 42 | 43 | guard let product = products.first else { 44 | throw Error.productNotFound 45 | } 46 | 47 | let result = try await product.purchase() 48 | 49 | switch result { 50 | case .success(let verificationResult): 51 | let transaction = try verificationResult.payloadValue 52 | await transaction.finish() 53 | 54 | return try await getUserEntitlements() 55 | case .userCancelled: 56 | throw Error.userCancelledPurchase 57 | default: 58 | throw Error.failedToPurchase 59 | } 60 | } catch { 61 | throw error 62 | } 63 | } 64 | 65 | public func checkTrialEligibility(productId: String) async throws -> Bool { 66 | // Retrieve the product for the given productId 67 | let products = try await Product.products(for: [productId]) 68 | 69 | guard let product = products.first, let subscriptionInfo = product.subscription else { 70 | throw Error.productNotFound 71 | } 72 | 73 | let eligibility = await subscriptionInfo.isEligibleForIntroOffer 74 | return eligibility 75 | } 76 | 77 | public func restorePurchase() async throws -> [PurchasedEntitlement] { 78 | try await AppStore.sync() 79 | return try await getUserEntitlements() 80 | } 81 | 82 | public func getUserEntitlements() async throws -> [PurchasedEntitlement] { 83 | var allEntitlements = [PurchasedEntitlement]() 84 | 85 | for await verificationResult in Transaction.currentEntitlements { 86 | switch verificationResult { 87 | case .verified(let transaction): 88 | let isActive: Bool 89 | if let expirationDate = transaction.expirationDate { 90 | isActive = expirationDate >= Date() 91 | } else { 92 | isActive = transaction.revocationDate == nil 93 | } 94 | 95 | allEntitlements.append(PurchasedEntitlement( 96 | id: "\(transaction.id)", 97 | productId: transaction.productID, 98 | expirationDate: transaction.expirationDate, 99 | isActive: isActive, 100 | originalPurchaseDate: transaction.originalPurchaseDate, 101 | latestPurchaseDate: transaction.purchaseDate, 102 | ownershipType: EntitlementOwnershipOption(type: transaction.ownershipType), 103 | isSandbox: transaction.environment == .sandbox, 104 | isVerified: true 105 | )) 106 | case .unverified: 107 | break 108 | } 109 | } 110 | 111 | return allEntitlements 112 | } 113 | 114 | public func logIn(userId: String) async throws -> [PurchasedEntitlement] { 115 | // Nothing required for StoreKit 116 | try await getUserEntitlements() 117 | } 118 | 119 | public func updateProfileAttributes(attributes: PurchaseProfileAttributes) { 120 | // Nothing required for StoreKit 121 | } 122 | 123 | public func logOut() async throws { 124 | // Nothing required for StoreKit 125 | } 126 | } 127 | 128 | extension EntitlementOwnershipOption { 129 | init(type: Transaction.OwnershipType) { 130 | switch type { 131 | case .purchased: 132 | self = .purchased 133 | case .familyShared: 134 | self = .familyShared 135 | default: 136 | self = .unknown 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Tests/SwiftfulPurchasingTests/PurchaseManager+Tests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import SwiftUI 3 | import Foundation 4 | @testable import SwiftfulPurchasing 5 | 6 | @MainActor 7 | struct PurchaseManagerTests { 8 | 9 | @Test("PurchaseManager logs in successfully and updates entitlements") 10 | func testLogInSuccess() async throws { 11 | // Given 12 | let mockService = MockPurchaseService(activeEntitlements: [ 13 | PurchasedEntitlement( 14 | productId: "com.example.product", 15 | expirationDate: nil, 16 | isActive: true, 17 | originalPurchaseDate: Date(), 18 | latestPurchaseDate: Date(), 19 | ownershipType: .purchased, 20 | isSandbox: false, 21 | isVerified: true 22 | ) 23 | ]) 24 | let purchaseManager = PurchaseManager(service: mockService) 25 | 26 | // When 27 | try await purchaseManager.logIn(userId: "testUser", email: nil) 28 | 29 | // Then 30 | #expect(purchaseManager.entitlements.hasActiveEntitlement == true) 31 | #expect(purchaseManager.entitlements.count == 1) 32 | } 33 | 34 | @Test("PurchaseManager handles login failure and logs the error") 35 | func testLogInFailure() async throws { 36 | // Given 37 | let mockService = MockPurchaseService(activeEntitlements: []) 38 | let purchaseManager = PurchaseManager(service: mockService) 39 | 40 | // When 41 | try await purchaseManager.logIn(userId: "testUser", email: nil) 42 | 43 | // Then 44 | #expect(purchaseManager.entitlements.hasActiveEntitlement == false) 45 | #expect(purchaseManager.entitlements.isEmpty) 46 | } 47 | 48 | @Test("PurchaseManager logs out and clears entitlements") 49 | func testLogOut() async throws { 50 | // Given 51 | let mockService = MockPurchaseService(activeEntitlements: [ 52 | PurchasedEntitlement( 53 | productId: "com.example.product", 54 | expirationDate: nil, 55 | isActive: true, 56 | originalPurchaseDate: Date(), 57 | latestPurchaseDate: Date(), 58 | ownershipType: .purchased, 59 | isSandbox: false, 60 | isVerified: true 61 | ) 62 | ]) 63 | let purchaseManager = PurchaseManager(service: mockService) 64 | 65 | // When 66 | try await purchaseManager.logOut() 67 | 68 | // Then 69 | #expect(purchaseManager.entitlements.isEmpty) 70 | } 71 | 72 | @Test("PurchaseManager purchases product and updates entitlements") 73 | func testPurchaseProductSuccess() async throws { 74 | // Given 75 | let mockService = MockPurchaseService() 76 | let purchaseManager = PurchaseManager(service: mockService) 77 | 78 | // When 79 | let result = try await purchaseManager.purchaseProduct(productId: "com.example.product") 80 | 81 | // Then 82 | #expect(result.count == 1) 83 | #expect(purchaseManager.entitlements.hasActiveEntitlement == true) 84 | } 85 | 86 | @Test("PurchaseManager restores purchases and updates entitlements") 87 | func testRestorePurchaseSuccess() async throws { 88 | // Given 89 | let mockService = MockPurchaseService() 90 | let purchaseManager = PurchaseManager(service: mockService) 91 | 92 | // When 93 | let result = try await purchaseManager.restorePurchase() 94 | 95 | // Then 96 | #expect(result.count == 1) 97 | #expect(purchaseManager.entitlements.hasActiveEntitlement == true) 98 | } 99 | 100 | @Test("PurchaseManager handles entitlement update correctly after restore") 101 | func testUpdateActiveEntitlementsAfterRestore() async throws { 102 | // Given 103 | let mockService = MockPurchaseService() 104 | let purchaseManager = PurchaseManager(service: mockService) 105 | 106 | // When 107 | try! await purchaseManager.restorePurchase() 108 | 109 | // Then 110 | #expect(purchaseManager.entitlements.count == 1) 111 | } 112 | } 113 | --------------------------------------------------------------------------------