diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 2f7369e..c2be810 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ C49EF0262BD80AE80077B5AA /* SubscriptionInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0252BD80AE80077B5AA /* SubscriptionInfoView.swift */; }; C49EF0392BDFF4600077B5AA /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C49EF0372BDFF3000077B5AA /* LeStorage.framework */; }; C49EF03A2BDFF4600077B5AA /* LeStorage.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C49EF0372BDFF3000077B5AA /* LeStorage.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C49EF03C2BE15AF80077B5AA /* String+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF03B2BE15AF80077B5AA /* String+Crypto.swift */; }; + C49EF03E2BE160720077B5AA /* Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF03D2BE160720077B5AA /* Key.swift */; }; + C49EF0422BE23BF50077B5AA /* PaymentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0412BE23BF50077B5AA /* PaymentTests.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 */; }; @@ -311,6 +314,9 @@ C49EF01A2BD6A1E80077B5AA /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = ""; }; C49EF0252BD80AE80077B5AA /* SubscriptionInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInfoView.swift; sourceTree = ""; }; C49EF0372BDFF3000077B5AA /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C49EF03B2BE15AF80077B5AA /* String+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Crypto.swift"; sourceTree = ""; }; + C49EF03D2BE160720077B5AA /* Key.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Key.swift; sourceTree = ""; }; + C49EF0412BE23BF50077B5AA /* PaymentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTests.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 = ""; }; @@ -634,6 +640,7 @@ isa = PBXGroup; children = ( C425D4112B6D249E002A7B48 /* PadelClubTests.swift */, + C49EF0412BE23BF50077B5AA /* PaymentTests.swift */, ); path = PadelClubTests; sourceTree = ""; @@ -1169,10 +1176,12 @@ FFF8ACD02B9238A2008466FA /* Manager */ = { isa = PBXGroup; children = ( + FF6EC9072B947A1E00EA7F5A /* Network */, FFA6D7862BB0B7A2003A31F3 /* CloudConvert.swift */, FF92680A2BCEE3E10080F940 /* ContactManager.swift */, FF1DC55A2BAB80C400FD8220 /* DisplayContext.swift */, FFA6D7842BB0B795003A31F3 /* FileImportManager.swift */, + C49EF03D2BE160720077B5AA /* Key.swift */, FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */, FF92680C2BCEE5EA0080F940 /* NetworkMonitor.swift */, FF8F26352BAD523300650388 /* PadelRule.swift */, @@ -1180,7 +1189,6 @@ FF0EC51D2BB16F680056B6D1 /* SwiftParser.swift */, FF1DC5582BAB767000FD8220 /* Tips.swift */, C49EF01A2BD6A1E80077B5AA /* URLs.swift */, - FF6EC9072B947A1E00EA7F5A /* Network */, ); path = Manager; sourceTree = ""; @@ -1188,17 +1196,18 @@ FFF8ACD72B923F26008466FA /* Extensions */ = { isa = PBXGroup; children = ( - FFF8ACD52B923960008466FA /* URL+Extensions.swift */, - FF5D0D862BB48AFD005CB568 /* NumberFormatter+Extensions.swift */, - FFF8ACD82B923F3C008466FA /* String+Extensions.swift */, - FFF8ACDA2B923F48008466FA /* Date+Extensions.swift */, - FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */, - FF6EC9032B9479F500EA7F5A /* Sequence+Extensions.swift */, - FF6EC9082B947A5300EA7F5A /* FixedWidthInteger+Extensions.swift */, FF6EC90A2B947AC000EA7F5A /* Array+Extensions.swift */, + FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */, FF5D0D732BB41DF8005CB568 /* Color+Extensions.swift */, - FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */, + FFF8ACDA2B923F48008466FA /* Date+Extensions.swift */, + FF6EC9082B947A5300EA7F5A /* FixedWidthInteger+Extensions.swift */, C44B79102BBDA63A00906534 /* Locale+Extensions.swift */, + FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */, + FF5D0D862BB48AFD005CB568 /* NumberFormatter+Extensions.swift */, + FF6EC9032B9479F500EA7F5A /* Sequence+Extensions.swift */, + C49EF03B2BE15AF80077B5AA /* String+Crypto.swift */, + FFF8ACD82B923F3C008466FA /* String+Extensions.swift */, + FFF8ACD52B923960008466FA /* URL+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -1550,6 +1559,7 @@ FFBF065E2BBD8040009D6715 /* MatchListView.swift in Sources */, C425D4012B6D249D002A7B48 /* PadelClubApp.swift in Sources */, FF8F26432BADFE5B00650388 /* TournamentSettingsView.swift in Sources */, + C49EF03C2BE15AF80077B5AA /* String+Crypto.swift in Sources */, FF4C7F022BBBD7150031B6A3 /* TabItemModifier.swift in Sources */, FFDDD40C2B93B2BB00C91A49 /* DeferredViewModifier.swift in Sources */, FFD784042B91C280000F62A6 /* EmptyActivityView.swift in Sources */, @@ -1561,6 +1571,7 @@ FF967D012BAEF0B400A9A3BD /* MatchSummaryView.swift in Sources */, FF8F26452BAE0A3400650388 /* TournamentDurationManagerView.swift in Sources */, FF1DC5532BAB354A00FD8220 /* MockData.swift in Sources */, + C49EF03E2BE160720077B5AA /* Key.swift in Sources */, FF967D092BAF3D4000A9A3BD /* TeamDetailView.swift in Sources */, FF5DA18F2BB9268800A33061 /* GroupStageSettingsView.swift in Sources */, FF663FBE2BE019EC0031AE83 /* TournamentFilterView.swift in Sources */, @@ -1610,6 +1621,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C49EF0422BE23BF50077B5AA /* PaymentTests.swift in Sources */, C425D4122B6D249E002A7B48 /* PadelClubTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PadelClub/Data/MockData.swift b/PadelClub/Data/MockData.swift index 11230c9..d1144c5 100644 --- a/PadelClub/Data/MockData.swift +++ b/PadelClub/Data/MockData.swift @@ -77,6 +77,6 @@ extension TeamRegistration { extension PlayerRegistration { static func mock() -> PlayerRegistration { - PlayerRegistration(firstName: "Raz", lastName: "Sark", sex: 1) + PlayerRegistration(firstName: "Raz", lastName: "Shark", sex: 1) } } diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index d2a4904..c26433c 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -9,7 +9,7 @@ import Foundation import LeStorage @Observable -class Tournament : ModelObject, Storable { +class Tournament : ModelObject, Storable, ObservableObject { static func resourceName() -> String { "tournaments" } var id: String = Store.randomId() @@ -41,10 +41,10 @@ class Tournament : ModelObject, Storable { var qualifiedPerGroupStage: Int var teamsPerGroupStage: Int var entryFee: Double? - var payment: TournamentPayment? = nil + var payment: Data? = nil var additionalEstimationDuration: Int = 0 var isDeleted: Bool = false - var isCanceled: Bool = false + var isCanceled: Data? = nil @ObservationIgnored var navigationPath: [Screen] = [] @@ -80,7 +80,8 @@ class Tournament : ModelObject, Storable { self.entryFee = entryFee } - enum TournamentPayment: Int { + /// Warning: if the enum has more than 10 cases, the payment algo is broken + enum TournamentPayment: Int, CaseIterable { case free, unit, subscriptionUnit, unlimited var isSubscription: Bool { @@ -1083,7 +1084,126 @@ class Tournament : ModelObject, Storable { func courtName(atIndex courtIndex: Int) -> String { courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) } + + // MARK: - Payments & Crypto + + fileprivate var _currentPayment: TournamentPayment? = nil + fileprivate var _currentCanceled: Bool? = nil + + fileprivate let _numberFormatter: NumberFormatter = NumberFormatter() + + func setPayment(_ payment: TournamentPayment) { + + let max: Int = TournamentPayment.allCases.count + self._currentPayment = payment + var sequence = (1...18).map { _ in Int.random(in: (0.. TournamentPayment? { + if let payment { + do { + let decoded: String = try payment.decryptData(pass: Key.pass.rawValue) + let sequence = decoded.compactMap { _numberFormatter.number(from: String($0))?.intValue } + return TournamentPayment(rawValue: sequence[18]) + } catch { + Logger.error(error) + } + } + return nil + } + + func setCanceled(_ canceled: Bool) { + + let max: Int = 9 + self._currentCanceled = canceled + var sequence = (1...18).map { _ in Int.random(in: (0.. Bool? { + if let isCanceled { + do { + let decoded: String = try isCanceled.decryptData(pass: Key.pass.rawValue) + let sequence = decoded.compactMap { _numberFormatter.number(from: String($0))?.intValue } + return Bool.decodeInt(sequence[18]) + } catch { + Logger.error(error) + } + } + return nil + } + + enum PaymentError: Error { + case cantPayTournament + } + + func payIfNecessary() throws { + if self.currentPayment != nil { return } + if let payment = Guard.main.paymentForNewTournament() { + self.setPayment(payment) + return + } + throw PaymentError.cantPayTournament + } + +} + +fileprivate extension Bool { + var encodedValue: Int { + switch self { + case true: + return Int.random(in: (0...4)) + case false: + return Int.random(in: (5...9)) + } + } + static func decodeInt(_ int: Int) -> Bool { + switch int { + case (0...4): + return true + default: + return false + } + } } extension Tournament { @@ -1119,7 +1239,8 @@ extension Tournament { case _entryFee = "entryFee" case _additionalEstimationDuration = "additionalEstimationDuration" case _isDeleted = "isDeleted" - case _isCanceled = "isCanceled" + case _isCanceled = "localId" + case _payment = "globalId" } } diff --git a/PadelClub/Extensions/String+Crypto.swift b/PadelClub/Extensions/String+Crypto.swift new file mode 100644 index 0000000..83b97de --- /dev/null +++ b/PadelClub/Extensions/String+Crypto.swift @@ -0,0 +1,47 @@ +// +// String+Crypto.swift +// PadelClub +// +// Created by Laurent Morvillier on 30/04/2024. +// + +import Foundation +import CryptoKit + +enum CryptoError: Error { + case invalidUTF8 + case cantConvertUTF8 + case invalidBase64String + case nilSeal +} + +extension Data { + + func encrypt(pass: String) throws -> Data { + let key = try self._createSymmetricKey(fromString: pass) + let sealedBox = try AES.GCM.seal(self, using: key) + if let combined = sealedBox.combined { + return combined + } + throw CryptoError.nilSeal + } + + func decryptData(pass: String) throws -> String { + let key = try self._createSymmetricKey(fromString: pass) + let sealedBox = try AES.GCM.SealedBox(combined: self) + let decryptedData = try AES.GCM.open(sealedBox, using: key) + guard let decryptedMessage = String(data: decryptedData, encoding: .utf8) else { + throw CryptoError.invalidUTF8 + } + return decryptedMessage + } + + fileprivate func _createSymmetricKey(fromString keyString: String) throws -> SymmetricKey { + guard let keyData = Data(base64Encoded: keyString) else { + throw CryptoError.invalidBase64String + } + return SymmetricKey(data: keyData) + } + +} + diff --git a/PadelClub/Manager/Key.swift b/PadelClub/Manager/Key.swift new file mode 100644 index 0000000..8eb9e60 --- /dev/null +++ b/PadelClub/Manager/Key.swift @@ -0,0 +1,12 @@ +// +// Key.swift +// PadelClub +// +// Created by Laurent Morvillier on 30/04/2024. +// + +import Foundation + +enum Key: String { + case pass = "Aa9QDV1G5MP9ijF2FTFasibNbS/Zun4qXrubIL2P+Ik=" +} diff --git a/PadelClub/ViewModel/FederalDataViewModel.swift b/PadelClub/ViewModel/FederalDataViewModel.swift index 30e0fc2..5d71add 100644 --- a/PadelClub/ViewModel/FederalDataViewModel.swift +++ b/PadelClub/ViewModel/FederalDataViewModel.swift @@ -66,6 +66,7 @@ class FederalDataViewModel { } func isTournamentValidForFilters(_ tournament: Tournament) -> Bool { + if tournament.isDeleted { return false } let firstPart = (levels.isEmpty || levels.contains(tournament.level)) && (categories.isEmpty || categories.contains(tournament.category)) diff --git a/PadelClub/Views/Calling/CallView.swift b/PadelClub/Views/Calling/CallView.swift index 2b85926..9d21c2b 100644 --- a/PadelClub/Views/Calling/CallView.swift +++ b/PadelClub/Views/Calling/CallView.swift @@ -81,7 +81,9 @@ struct CallView: View { var finalMessage: String { ContactType.callingGroupStageMessage(tournament: tournament, startDate: callDate, roundLabel: roundLabel, matchFormat: matchFormat) } - + + // TODO: Guard + var body: some View { let callWord = teams.allSatisfy({ $0.called() }) ? "Reconvoquer" : "Convoquer" HStack { diff --git a/PadelClub/Views/Calling/Components/MenuWarningView.swift b/PadelClub/Views/Calling/Components/MenuWarningView.swift index 7e7d476..d8aacf8 100644 --- a/PadelClub/Views/Calling/Components/MenuWarningView.swift +++ b/PadelClub/Views/Calling/Components/MenuWarningView.swift @@ -23,6 +23,7 @@ struct MenuWarningView: View { return nil } + // TODO: Guard @ViewBuilder private func _actionView(players: [PlayerRegistration], privateMode: Bool = false) -> some View { Button("Message") { diff --git a/PadelClub/Views/Calling/SendToAllView.swift b/PadelClub/Views/Calling/SendToAllView.swift index c5ae77c..ec157ed 100644 --- a/PadelClub/Views/Calling/SendToAllView.swift +++ b/PadelClub/Views/Calling/SendToAllView.swift @@ -26,6 +26,7 @@ struct SendToAllView: View { } } } + // TODO: Guard var body: some View { NavigationStack { diff --git a/PadelClub/Views/Match/MatchDetailView.swift b/PadelClub/Views/Match/MatchDetailView.swift index 72887eb..9d23fd2 100644 --- a/PadelClub/Views/Match/MatchDetailView.swift +++ b/PadelClub/Views/Match/MatchDetailView.swift @@ -26,7 +26,8 @@ struct MatchDetailView: View { @State private var showDetails: Bool = false @State private var contactType: ContactType? = nil @State private var sentError: ContactManagerError? = nil - + @State private var showSubscriptionView: Bool = false + var messageSentFailed: Binding { Binding { sentError != nil @@ -136,7 +137,14 @@ struct MatchDetailView: View { if match.isReady() { Section { - inputScoreView + RowButtonView("Saisir les résultats", systemImage: "list.clipboard") { + do { +// try self.tournament.payIfNecessary() + scoreType = .edition + } catch { + self.showSubscriptionView = true + } + } } } @@ -166,9 +174,11 @@ struct MatchDetailView: View { menuView } .sheet(isPresented: $showDetails) { - MatchTeamDetailView(match: match) - .tint(.master) + MatchTeamDetailView(match: match).tint(.master) } + .sheet(isPresented: self.$showSubscriptionView, content: { + SubscriptionView(showLackOfPlanMessage: true) + }) .sheet(item: $scoreType, onDismiss: { if match.hasEnded() { dismiss() @@ -368,12 +378,6 @@ struct MatchDetailView: View { // } } - var inputScoreView: some View { - RowButtonView("Saisir les résultats", systemImage: "list.clipboard") { - scoreType = .edition - } - } - var editionView: some View { DisclosureGroup(isExpanded: $isEditing) { startingOptionView diff --git a/PadelClub/Views/Player/Components/EditablePlayerView.swift b/PadelClub/Views/Player/Components/EditablePlayerView.swift index b75a617..5665d34 100644 --- a/PadelClub/Views/Player/Components/EditablePlayerView.swift +++ b/PadelClub/Views/Player/Components/EditablePlayerView.swift @@ -31,7 +31,8 @@ struct EditablePlayerView: View { } } } - + // TODO: Guard + @ViewBuilder func computedPlayerView(_ player: PlayerRegistration) -> some View { VStack(alignment: .leading) { diff --git a/PadelClub/Views/Subscription/Guard.swift b/PadelClub/Views/Subscription/Guard.swift index e3e4c85..5f76d42 100644 --- a/PadelClub/Views/Subscription/Guard.swift +++ b/PadelClub/Views/Subscription/Guard.swift @@ -184,16 +184,16 @@ import LeStorage return Tournament.TournamentPayment.unlimited case .fivePerMonth: if let purchaseDate = self.currentBestPlan?.originalPurchaseDate { - let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate } + let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.currentCanceled == false } if tournaments.count < StoreItem.five { return Tournament.TournamentPayment.subscriptionUnit } } return nil default: - let subscriptionPayed = DataStore.shared.tournaments.filter { $0.payment?.isSubscription == true } +// let subscriptionPayed = DataStore.shared.tournaments.filter { $0.payment?.isSubscription == true } - let unitlyPayed = DataStore.shared.tournaments.count - subscriptionPayed.count + let unitlyPayed = DataStore.shared.tournaments.filter { $0.currentPayment == .unit && $0.currentCanceled == false }.count if unitlyPayed == 0 { return Tournament.TournamentPayment.free } @@ -207,7 +207,7 @@ import LeStorage } var remainingTournaments: Int { - let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == Tournament.TournamentPayment.unit }.count + let unitlyPayed = DataStore.shared.tournaments.filter { $0.currentPayment == Tournament.TournamentPayment.unit }.count let tournamentCreditCount = self._purchasedTournamentCount() Logger.log("total count = \(DataStore.shared.tournaments.count), unitlyPayed = \(unitlyPayed), purchased = \(tournamentCreditCount) ") return tournamentCreditCount - unitlyPayed diff --git a/PadelClubTests/PaymentTests.swift b/PadelClubTests/PaymentTests.swift new file mode 100644 index 0000000..3f0b455 --- /dev/null +++ b/PadelClubTests/PaymentTests.swift @@ -0,0 +1,46 @@ +// +// PaymentTests.swift +// PadelClubTests +// +// Created by Laurent Morvillier on 01/05/2024. +// + +import XCTest +@testable import PadelClub + +final class PaymentTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testPayments() throws { + + let tournament = Tournament.fake() + tournament.setPayment(.free) + assert(tournament.decryptPayment() == .free) + tournament.setPayment(.subscriptionUnit) + assert(tournament.decryptPayment() == .subscriptionUnit) + tournament.setPayment(.unit) + assert(tournament.decryptPayment() == .unit) + tournament.setPayment(.unlimited) + assert(tournament.decryptPayment() == .unlimited) + + } + + func testCanceled() throws { + + let tournament = Tournament.fake() + + tournament.setCanceled(true) + assert(tournament.decryptCanceled() == true) + tournament.setCanceled(false) + assert(tournament.decryptCanceled() == false) + + } + +}