├── .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 |
--------------------------------------------------------------------------------