diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 0c8a95c..2475782 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ C425D41E2B6D249E002A7B48 /* PadelClubUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D41D2B6D249E002A7B48 /* PadelClubUITestsLaunchTests.swift */; }; C44B79112BBDA63A00906534 /* Locale+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44B79102BBDA63A00906534 /* Locale+Extensions.swift */; }; C45BAE3B2BC6DF10002EEC8A /* SyncedProducts.storekit in Resources */ = {isa = PBXBuildFile; fileRef = C45BAE3A2BC6DF10002EEC8A /* SyncedProducts.storekit */; }; + C45BAE442BCA753E002EEC8A /* Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45BAE432BCA753E002EEC8A /* Purchase.swift */; }; C4A47D5A2B6D383C00ADC637 /* Tournament.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D592B6D383C00ADC637 /* Tournament.swift */; }; C4A47D5E2B6D38EC00ADC637 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D5D2B6D38EC00ADC637 /* DataStore.swift */; }; C4A47D632B6D3D6500ADC637 /* Club.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D622B6D3D6500ADC637 /* Club.swift */; }; @@ -273,6 +274,7 @@ C425D44E2B6D24E1002A7B48 /* LeStorage.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = LeStorage.xcodeproj; path = ../../LeStorage/LeStorage.xcodeproj; sourceTree = ""; }; C44B79102BBDA63A00906534 /* Locale+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Extensions.swift"; sourceTree = ""; }; C45BAE3A2BC6DF10002EEC8A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = ""; }; + C45BAE432BCA753E002EEC8A /* Purchase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Purchase.swift; sourceTree = ""; }; C4A47D592B6D383C00ADC637 /* Tournament.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tournament.swift; sourceTree = ""; }; C4A47D5D2B6D38EC00ADC637 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; C4A47D622B6D3D6500ADC637 /* Club.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Club.swift; sourceTree = ""; }; @@ -652,6 +654,7 @@ C4A47D882B7BBB5000ADC637 /* Subscription */ = { isa = PBXGroup; children = ( + C45BAE432BCA753E002EEC8A /* Purchase.swift */, C4A47D892B7BBB6500ADC637 /* SubscriptionView.swift */, C4A47D8E2B7BBBEC00ADC637 /* Guard.swift */, C4A47D8D2B7BBBEC00ADC637 /* StoreManager.swift */, @@ -1314,6 +1317,7 @@ FF3B60A32BC49BBC008C2E66 /* MatchScheduler.swift in Sources */, FF5DA1952BB927E800A33061 /* GenericDestinationPickerView.swift in Sources */, FF8F26542BAE1E4400650388 /* TableStructureView.swift in Sources */, + C45BAE442BCA753E002EEC8A /* Purchase.swift in Sources */, FF6EC8FE2B94792300EA7F5A /* Screen.swift in Sources */, FF967CEE2BAECBD700A9A3BD /* Round.swift in Sources */, FF3F74FF2B91A2D4004CFE0E /* AgendaDestination.swift in Sources */, diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 63ecbed..272356a 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -43,6 +43,7 @@ class Tournament : ModelObject, Storable { var entryFee: Double? var maleUnrankedValue: Int? var femaleUnrankedValue: Int? + var payment: TournamentPayment = .free @ObservationIgnored var navigationPath: [Screen] = [] @@ -81,6 +82,10 @@ class Tournament : ModelObject, Storable { self.teamSorting = teamSorting ?? federalLevelCategory.defaultTeamSortingType } + enum TournamentPayment: Int { + case free, unit, subscriptionUnit, unlimited + } + enum State { case initial case build diff --git a/PadelClub/Views/Subscription/Guard.swift b/PadelClub/Views/Subscription/Guard.swift index 39b203a..e138366 100644 --- a/PadelClub/Views/Subscription/Guard.swift +++ b/PadelClub/Views/Subscription/Guard.swift @@ -20,6 +20,8 @@ import LeStorage var updateListenerTask: Task? = nil + fileprivate var _userPurchases: [Purchase] = [] + override init() { super.init() @@ -28,14 +30,32 @@ import LeStorage Task { do { - try await self.refreshPurchasedProducts() + try await self._retrievePrivateServerPurchases() + try await self.refreshPurchasedAppleProducts() } catch { Logger.error(error) } } } - func refreshPurchasedProducts() async throws { + fileprivate func _uploadTransaction(_ transaction: StoreKit.Transaction) async throws { + + let service = try Store.main.service() + var purchase = try transaction.purchase() + +// if let apiCall = try self._callForInstance(purchase, method: Method.post) { +// +// } + + + } + + fileprivate func _retrievePrivateServerPurchases() async throws { + let service = try Store.main.service() + self._userPurchases = try await service.get() + } + + func refreshPurchasedAppleProducts() async throws { // Iterate through the user's purchased products. for await verificationResult in Transaction.currentEntitlements { @@ -73,7 +93,7 @@ import LeStorage switch result { case .unverified: //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user. - throw StoreError.failedVerification + throw StoreManagerError.failedVerification case .verified(let safe): //If the transaction is verified, unwrap and return it. return safe @@ -85,11 +105,15 @@ import LeStorage if transaction.revocationDate == nil { // If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`. purchasedTransactions.insert(transaction) + do { + try await self._uploadTransaction(transaction) + } catch { + Logger.error(error) + } } else { // If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`. purchasedTransactions.remove(transaction) } - self._updateBestPlan() } @@ -106,7 +130,7 @@ import LeStorage var currentPlan: StoreItem? { #if DEBUG - return .unlimited + return .monthlyUnlimited #else if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) { return plan @@ -120,14 +144,74 @@ import LeStorage #endif } + fileprivate func _userFilteredPurchases() -> [StoreKit.Transaction] { + return self.purchasedTransactions.filter { transaction in + return self._userPurchases.contains(where: { $0.identifier == transaction.id } ) + } + } + + /// Update best plan by filtering Apple purchases with registered purchases by the user fileprivate func _updateBestPlan() { - if let monthly = self.purchasedTransactions.first(where: { $0.productID == StoreItem.unlimited.rawValue }) { - self.currentBestPlan = monthly + // Make sure the purchase has been done with the logged user + let privatePurchases = self._userFilteredPurchases() + + if let unlimited = privatePurchases.first(where: { $0.productID == StoreItem.monthlyUnlimited.rawValue }) { + self.currentBestPlan = unlimited + } else if let fivePerMonth = privatePurchases.first(where: { $0.productID == StoreItem.fivePerMonth.rawValue }) { + self.currentBestPlan = fivePerMonth } else { self.currentBestPlan = nil } } + fileprivate func _purchasedTournamentCount() -> Int { + let units = self._userFilteredPurchases().filter { $0.productID == StoreItem.unit.rawValue } + return units.reduce(0) { $0 + $1.purchasedQuantity } + } + + func canAddTournament() -> Bool { + if self.currentPlan == .monthlyUnlimited { + return true + } + let tournamentCount = DataStore.shared.tournaments.count + let tournamentCreditCount = self._purchasedTournamentCount() + return tournamentCreditCount > tournamentCount + } + + func paymentForNewTournament() -> Tournament.TournamentPayment? { + if self.currentPlan == .monthlyUnlimited { + return Tournament.TournamentPayment.unlimited + } else if self.currentPlan == .fivePerMonth, let purchaseDate = self.currentBestPlan?.originalPurchaseDate { + let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate } + if tournaments.count < 5 { + return Tournament.TournamentPayment.subscriptionUnit + } + } + + let tournamentCount = DataStore.shared.tournaments.count + if tournamentCount == 0 { + return Tournament.TournamentPayment.free + } + let tournamentCreditCount = self._purchasedTournamentCount() + if tournamentCreditCount > tournamentCount { + return Tournament.TournamentPayment.unit + } + return nil + } + +} + +fileprivate extension StoreKit.Transaction { + + func purchase() throws -> Purchase { + let userId = try Store.main.currentUserUUID().uuidString + return Purchase(user: userId, + identifier: self.id, + purchaseDate: self.purchaseDate, + productId: self.productID) + + } + } diff --git a/PadelClub/Views/Subscription/Purchase.swift b/PadelClub/Views/Subscription/Purchase.swift new file mode 100644 index 0000000..ad3f823 --- /dev/null +++ b/PadelClub/Views/Subscription/Purchase.swift @@ -0,0 +1,28 @@ +// +// Purchase.swift +// LeStorage +// +// Created by Laurent Morvillier on 12/04/2024. +// + +import Foundation +import LeStorage + +public class Purchase: ModelObject, Storable { + public static func resourceName() -> String { return "purchases" } + + public var id: String = Store.randomId() + + public var user: String + public var identifier: UInt64 + public var purchaseDate: Date + public var productId: String + + public init(user: String, identifier: UInt64, purchaseDate: Date, productId: String) { + self.user = user + self.identifier = identifier + self.purchaseDate = purchaseDate + self.productId = productId + } + +} diff --git a/PadelClub/Views/Subscription/StoreItem.swift b/PadelClub/Views/Subscription/StoreItem.swift index 8fa641d..e640f9b 100644 --- a/PadelClub/Views/Subscription/StoreItem.swift +++ b/PadelClub/Views/Subscription/StoreItem.swift @@ -8,7 +8,8 @@ import Foundation enum StoreItem: String, Identifiable, CaseIterable { - case unlimited = "app.padelclub.unlimited" + case monthlyUnlimited = "app.padelclub.tournament.subscription.unlimited" + case fivePerMonth = "app.padelclub.tournament.subscription.five.per.month" case unit = "app.padelclub.tournament.unit" var id: String { return self.rawValue } @@ -21,16 +22,17 @@ enum StoreItem: String, Identifiable, CaseIterable { var systemImage: String { switch self { - case .unlimited: return "star.circle.fill" + case .monthlyUnlimited: return "infinity.circle.fill" + case .fivePerMonth: return "star.circle.fill" case .unit: return "tennisball.circle.fill" } } var isConsumable: Bool { switch self { - case .unlimited: return false + case .monthlyUnlimited, .fivePerMonth: return false case .unit: return true } } - + } diff --git a/PadelClub/Views/Subscription/StoreManager.swift b/PadelClub/Views/Subscription/StoreManager.swift index 2de98dd..19f5693 100644 --- a/PadelClub/Views/Subscription/StoreManager.swift +++ b/PadelClub/Views/Subscription/StoreManager.swift @@ -9,7 +9,7 @@ import Foundation import StoreKit import LeStorage -public enum StoreError: Error { +public enum StoreManagerError: Error { case failedVerification } diff --git a/PadelClub/Views/Subscription/SubscriptionView.swift b/PadelClub/Views/Subscription/SubscriptionView.swift index f9776f8..5dd1633 100644 --- a/PadelClub/Views/Subscription/SubscriptionView.swift +++ b/PadelClub/Views/Subscription/SubscriptionView.swift @@ -192,7 +192,7 @@ struct SubscriptionView: View { do { self.isRestoring = true - try await Guard.main.refreshPurchasedProducts() + try await Guard.main.refreshPurchasedAppleProducts() self.isRestoring = false } catch { self.isRestoring = false diff --git a/PadelClub/Views/User/ChangePasswordView.swift b/PadelClub/Views/User/ChangePasswordView.swift index bcec891..c2f48ca 100644 --- a/PadelClub/Views/User/ChangePasswordView.swift +++ b/PadelClub/Views/User/ChangePasswordView.swift @@ -35,11 +35,9 @@ struct ChangePasswordView: View { } fileprivate func _changePassword() { - guard let service = Store.main.service else { - return - } Task { do { + let service = try Store.main.service() _ = try await service.changePassword( oldPassword: self.oldPassword, password1: self.password1, diff --git a/PadelClub/Views/User/LoginView.swift b/PadelClub/Views/User/LoginView.swift index 58343de..9b24fb1 100644 --- a/PadelClub/Views/User/LoginView.swift +++ b/PadelClub/Views/User/LoginView.swift @@ -74,11 +74,9 @@ struct LoginView: View { } fileprivate func _login() { - guard let service = Store.main.service else { - return - } Task { do { + let service = try Store.main.service() let user: User = try await service.login( username: self.username, password: self.password) @@ -113,7 +111,8 @@ struct EmailConfirmationView: View { Task { do { - try await Store.main.service?.forgotPassword(email: self.email) + let service = try Store.main.service() + try await service.forgotPassword(email: self.email) } catch { Logger.error(error) } diff --git a/PadelClub/Views/User/UserCreationView.swift b/PadelClub/Views/User/UserCreationView.swift index 4c86b3f..b56d698 100644 --- a/PadelClub/Views/User/UserCreationView.swift +++ b/PadelClub/Views/User/UserCreationView.swift @@ -108,10 +108,6 @@ struct UserCreationFormView: View { return } - guard let service = Store.main.service else { - return - } - self.isLoading = true Task { @@ -125,6 +121,7 @@ struct UserCreationFormView: View { phone: self.phone, country: self.countries[self.selectedCountryIndex]) + let service = try Store.main.service() let _: User = try await service.createAccount(user: userCreationForm) self.isLoading = false