From cf3cb3ec18c9b7f1cd4b6f04248f3eda78863a2c Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 19 Sep 2024 11:29:57 +0200 Subject: [PATCH] Fix issue with Purchase, add fields to Purchase + convenience methods to encode/decode + fix tests --- PadelClub.xcodeproj/project.pbxproj | 18 ++- PadelClub/Data/Tournament.swift | 8 +- .../CodingContainer+Extensions.swift | 49 ++++++++ .../Utils/{Key.swift => CryptoKey.swift} | 2 +- .../Views/Navigation/Umpire/UmpireView.swift | 15 ++- .../Views/Tournament/Subscription/Guard.swift | 106 +++++++++++------- .../Tournament/Subscription/Purchase.swift | 55 ++++++++- PadelClubTests/ServerDataTests.swift | 69 ++++++------ PadelClubTests/TokenExemptionTests.swift | 14 +-- PadelClubTests/UserDataTests.swift | 10 +- 10 files changed, 238 insertions(+), 108 deletions(-) create mode 100644 PadelClub/Extensions/CodingContainer+Extensions.swift rename PadelClub/Utils/{Key.swift => CryptoKey.swift} (87%) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index f669a1a..cac1ff1 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 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 */; }; C49EF0422BE23BF50077B5AA /* PaymentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0412BE23BF50077B5AA /* PaymentTests.swift */; }; - C49EF0442BE286780077B5AA /* Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0432BE286780077B5AA /* Key.swift */; }; + C49EF0442BE286780077B5AA /* CryptoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0432BE286780077B5AA /* CryptoKey.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 */; }; @@ -46,6 +46,8 @@ C4A47DB32B86387500ADC637 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DB22B86387500ADC637 /* AccountView.swift */; }; C4B3A1552C2581DA0078EAA8 /* Patcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B3A1542C2581DA0078EAA8 /* Patcher.swift */; }; C4C01D982C481C0C0059087C /* CapsuleViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */; }; + C4C33F762C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; }; + C4C33F772C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; }; C4EC6F572BE92CAC000CEAB4 /* local.plist in Resources */ = {isa = PBXBuildFile; fileRef = C4EC6F562BE92CAC000CEAB4 /* local.plist */; }; C4EC6F592BE92D88000CEAB4 /* PListReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EC6F582BE92D88000CEAB4 /* PListReader.swift */; }; C4FC2E272C2AABC90021F3BF /* PasswordField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E262C2AABC90021F3BF /* PasswordField.swift */; }; @@ -290,7 +292,7 @@ FF70FB402C90584900129CC2 /* ClubRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D0D882BB4935C005CB568 /* ClubRowView.swift */; }; FF70FB412C90584900129CC2 /* ClubDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1DC5502BAB351300FD8220 /* ClubDetailView.swift */; }; FF70FB422C90584900129CC2 /* GroupStageCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9268022BCE94A30080F940 /* GroupStageCallingView.swift */; }; - FF70FB432C90584900129CC2 /* Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0432BE286780077B5AA /* Key.swift */; }; + FF70FB432C90584900129CC2 /* CryptoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0432BE286780077B5AA /* CryptoKey.swift */; }; FF70FB442C90584900129CC2 /* CashierSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF11627C2BCF941A000C4809 /* CashierSettingsView.swift */; }; FF70FB452C90584900129CC2 /* LoserRoundScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFCDE0D2BCC833600317DEF /* LoserRoundScheduleEditorView.swift */; }; FF70FB462C90584900129CC2 /* Club.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D622B6D3D6500ADC637 /* Club.swift */; }; @@ -609,7 +611,7 @@ 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 = ""; }; C49EF0412BE23BF50077B5AA /* PaymentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTests.swift; sourceTree = ""; }; - C49EF0432BE286780077B5AA /* Key.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Key.swift; sourceTree = ""; }; + C49EF0432BE286780077B5AA /* CryptoKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CryptoKey.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 = ""; }; @@ -625,6 +627,7 @@ C4A47DB22B86387500ADC637 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; C4B3A1542C2581DA0078EAA8 /* Patcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Patcher.swift; sourceTree = ""; }; C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleViewModifier.swift; sourceTree = ""; }; + C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodingContainer+Extensions.swift"; sourceTree = ""; }; C4EC6F562BE92CAC000CEAB4 /* local.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = local.plist; sourceTree = ""; }; C4EC6F582BE92D88000CEAB4 /* PListReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PListReader.swift; sourceTree = ""; }; C4FC2E262C2AABC90021F3BF /* PasswordField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordField.swift; sourceTree = ""; }; @@ -1626,7 +1629,7 @@ FFA6D7842BB0B795003A31F3 /* FileImportManager.swift */, FF1F4B722BFA00FB000B4573 /* HtmlGenerator.swift */, FF1F4B732BFA00FC000B4573 /* HtmlService.swift */, - C49EF0432BE286780077B5AA /* Key.swift */, + C49EF0432BE286780077B5AA /* CryptoKey.swift */, FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */, FF92680C2BCEE5EA0080F940 /* NetworkMonitor.swift */, FF8F26352BAD523300650388 /* PadelRule.swift */, @@ -1646,6 +1649,7 @@ children = ( FF6EC90A2B947AC000EA7F5A /* Array+Extensions.swift */, FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */, + C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */, FF5D0D732BB41DF8005CB568 /* Color+Extensions.swift */, FFF8ACDA2B923F48008466FA /* Date+Extensions.swift */, FF6EC9082B947A5300EA7F5A /* FixedWidthInteger+Extensions.swift */, @@ -2010,7 +2014,7 @@ FF5D0D892BB4935C005CB568 /* ClubRowView.swift in Sources */, FF1DC5512BAB351300FD8220 /* ClubDetailView.swift in Sources */, FF9268032BCE94A30080F940 /* GroupStageCallingView.swift in Sources */, - C49EF0442BE286780077B5AA /* Key.swift in Sources */, + C49EF0442BE286780077B5AA /* CryptoKey.swift in Sources */, FF11627D2BCF941A000C4809 /* CashierSettingsView.swift in Sources */, FFFCDE0E2BCC833600317DEF /* LoserRoundScheduleEditorView.swift in Sources */, C4A47D632B6D3D6500ADC637 /* Club.swift in Sources */, @@ -2068,6 +2072,7 @@ FF025AF12BD1AEBD00A86CF8 /* MatchFormatStorageView.swift in Sources */, FF3F74F62B919E45004CFE0E /* UmpireView.swift in Sources */, C4A47DAD2B85FCCD00ADC637 /* User.swift in Sources */, + C4C33F762C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */, FF967D012BAEF0B400A9A3BD /* MatchSummaryView.swift in Sources */, FF8F26452BAE0A3400650388 /* TournamentDurationManagerView.swift in Sources */, FF1DC5532BAB354A00FD8220 /* MockData.swift in Sources */, @@ -2278,7 +2283,7 @@ FF70FB402C90584900129CC2 /* ClubRowView.swift in Sources */, FF70FB412C90584900129CC2 /* ClubDetailView.swift in Sources */, FF70FB422C90584900129CC2 /* GroupStageCallingView.swift in Sources */, - FF70FB432C90584900129CC2 /* Key.swift in Sources */, + FF70FB432C90584900129CC2 /* CryptoKey.swift in Sources */, FF70FB442C90584900129CC2 /* CashierSettingsView.swift in Sources */, FF70FB452C90584900129CC2 /* LoserRoundScheduleEditorView.swift in Sources */, FF70FB462C90584900129CC2 /* Club.swift in Sources */, @@ -2336,6 +2341,7 @@ FF70FB7A2C90584900129CC2 /* MatchFormatStorageView.swift in Sources */, FF70FB7B2C90584900129CC2 /* UmpireView.swift in Sources */, FF70FB7C2C90584900129CC2 /* User.swift in Sources */, + C4C33F772C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */, FF70FB7D2C90584900129CC2 /* MatchSummaryView.swift in Sources */, FF70FB7E2C90584900129CC2 /* TournamentDurationManagerView.swift in Sources */, FF70FB7F2C90584900129CC2 /* MockData.swift in Sources */, diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index e042f11..54632ac 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -198,7 +198,7 @@ final class Tournament : ModelObject, Storable { if let data { do { - let decoded: String = try data.decryptData(pass: Key.pass.rawValue) + let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue) let sequence = decoded.compactMap { _numberFormatter.number(from: String($0))?.intValue } return TournamentPayment(rawValue: sequence[18]) } catch { @@ -212,7 +212,7 @@ final class Tournament : ModelObject, Storable { let data = try container.decodeIfPresent(Data.self, forKey: ._isCanceled) if let data { do { - let decoded: String = try data.decryptData(pass: Key.pass.rawValue) + let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue) let sequence = decoded.compactMap { _numberFormatter.number(from: String($0))?.intValue } return Bool.decodeInt(sequence[18]) } catch { @@ -321,7 +321,7 @@ final class Tournament : ModelObject, Storable { let stringCombo: [String] = sequence.map { $0.formatted() } let joined: String = stringCombo.joined(separator: "") if let data = joined.data(using: .utf8) { - let encryped: Data = try data.encrypt(pass: Key.pass.rawValue) + let encryped: Data = try data.encrypt(pass: CryptoKey.pass.rawValue) try container.encodeIfPresent(encryped, forKey: ._payment) } @@ -337,7 +337,7 @@ final class Tournament : ModelObject, Storable { let stringCombo: [String] = sequence.map { $0.formatted() } let joined: String = stringCombo.joined(separator: "") if let data = joined.data(using: .utf8) { - let encryped: Data = try data.encrypt(pass: Key.pass.rawValue) + let encryped: Data = try data.encrypt(pass: CryptoKey.pass.rawValue) try container.encode(encryped, forKey: ._isCanceled) } } diff --git a/PadelClub/Extensions/CodingContainer+Extensions.swift b/PadelClub/Extensions/CodingContainer+Extensions.swift new file mode 100644 index 0000000..ed3a808 --- /dev/null +++ b/PadelClub/Extensions/CodingContainer+Extensions.swift @@ -0,0 +1,49 @@ +// +// KeyedEncodingContainer+Extensions.swift +// PadelClub +// +// Created by Laurent Morvillier on 18/09/2024. +// + +import Foundation +import LeStorage + +extension KeyedDecodingContainer { + + func decodeEncrypted(key: Key) throws -> String { + let data = try self.decode(Data.self, forKey: key) + return try data.decryptData(pass: CryptoKey.pass.rawValue) + } + + func decodeEncryptedIfPresent(key: Key) throws -> String? { + let data = try self.decodeIfPresent(Data.self, forKey: key) + if let data { + return try data.decryptData(pass: CryptoKey.pass.rawValue) + } + return nil + } + +} + +extension KeyedEncodingContainer { + + mutating func encodeIfPresent(_ value: T?, forKey key: Key) throws { + guard let value else { + try self.encodeNil(forKey: key) + return + } + try self.encode(value, forKey: key) + } + + mutating func encodeAndEncryptIfPresent(_ value: Data?, forKey key: Key) throws { + guard let value else { + try encodeNil(forKey: key) + return + } + let encryped: Data = try value.encrypt(pass: CryptoKey.pass.rawValue) + try self.encode(encryped, forKey: key) + } + +} + + diff --git a/PadelClub/Utils/Key.swift b/PadelClub/Utils/CryptoKey.swift similarity index 87% rename from PadelClub/Utils/Key.swift rename to PadelClub/Utils/CryptoKey.swift index 8eb9e60..324ada1 100644 --- a/PadelClub/Utils/Key.swift +++ b/PadelClub/Utils/CryptoKey.swift @@ -7,6 +7,6 @@ import Foundation -enum Key: String { +enum CryptoKey: String { case pass = "Aa9QDV1G5MP9ijF2FTFasibNbS/Zun4qXrubIL2P+Ik=" } diff --git a/PadelClub/Views/Navigation/Umpire/UmpireView.swift b/PadelClub/Views/Navigation/Umpire/UmpireView.swift index 0740e0b..36a50fa 100644 --- a/PadelClub/Views/Navigation/Umpire/UmpireView.swift +++ b/PadelClub/Views/Navigation/Umpire/UmpireView.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreLocation import LeStorage +import StoreKit struct UmpireView: View { @@ -247,18 +248,22 @@ struct AccountRowView: View { struct ProductIdsView: View { - @State var ids: [String] = [] + @State var transactions: [StoreKit.Transaction] = [] var body: some View { VStack { List { - LabeledContent("count", value: String(ids.count)) - ForEach(self.ids) { id in - Text(id) + LabeledContent("count", value: String(self.transactions.count)) + ForEach(self.transactions) { transaction in + if let offerType = transaction.offerType?.rawValue { + LabeledContent(transaction.productID, value: "\(offerType)") + } else { + LabeledContent(transaction.productID, value: "no offer") + } } }.onAppear { Task { - self.ids = await Guard.main.productIds() + self.transactions = Array(Guard.main.purchasedTransactions) } } } diff --git a/PadelClub/Views/Tournament/Subscription/Guard.swift b/PadelClub/Views/Tournament/Subscription/Guard.swift index 89f4cb4..c092401 100644 --- a/PadelClub/Views/Tournament/Subscription/Guard.swift +++ b/PadelClub/Views/Tournament/Subscription/Guard.swift @@ -16,7 +16,7 @@ import LeStorage @Published private(set) var purchasedTransactions = Set() - var currentBestPlan: StoreKit.Transaction? = nil + var currentBestPurchase: Purchase? = nil var updateListenerTask: Task? = nil @@ -104,50 +104,64 @@ import LeStorage func updatePurchasedIdentifiers(_ transaction: StoreKit.Transaction) async { // Logger.log("\(transaction.productID) > purchase = \(transaction.originalPurchaseDate), exp date= \(transaction.expirationDate), rev date = \(transaction.revocationDate)") + +// Logger.log("purchase date = \(transaction.purchaseDate)") + do { if transaction.revocationDate == nil { // If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`. purchasedTransactions.insert(transaction) - try self._addPurchaseIfPossible(transaction: transaction) +// try self._addPurchaseIfPossible(transaction: transaction) } else { // If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`. purchasedTransactions.remove(transaction) - try self._updatePurchaseIfPossible(transaction: transaction) +// try self._updatePurchaseIfPossible(transaction: transaction) } + try self._updatePurchase(transaction: transaction) + } catch { Logger.error(error) } self._updateBestPlan() } - - fileprivate func _addPurchaseIfPossible(transaction: StoreKit.Transaction) throws { - - let purchases = DataStore.shared.purchases - - if purchases.hasLoaded { - if self._purchaseById(transaction.originalID) == nil { - let purchase: Purchase = try transaction.purchase() - try purchases.addOrUpdate(instance: purchase) - } - } - } - fileprivate func _updatePurchaseIfPossible(transaction: StoreKit.Transaction) throws { + fileprivate func _updatePurchase(transaction: StoreKit.Transaction) throws { let purchases = DataStore.shared.purchases - if purchases.hasLoaded { - if let existing: Purchase = self._purchaseById(transaction.originalID) { - existing.revocationDate = transaction.revocationDate - try purchases.addOrUpdate(instance: existing) - } + if let purchase = self._purchaseByTransactionId(transaction.originalID) { + purchase.revocationDate = transaction.revocationDate + purchase.expirationDate = transaction.expirationDate + purchase.purchaseDate = transaction.purchaseDate + try purchases.addOrUpdate(instance: purchase) + } else { + let purchase: Purchase = try transaction.purchase() + try purchases.addOrUpdate(instance: purchase) } } + +// fileprivate func _addPurchaseIfPossible(transaction: StoreKit.Transaction) throws { +// +// let purchases = DataStore.shared.purchases +// +// if self._purchaseByTransactionId(transaction.originalID) == nil { +// let purchase: Purchase = try transaction.purchase() +// try purchases.addOrUpdate(instance: purchase) +// } +// } +// +// fileprivate func _updatePurchaseIfPossible(transaction: StoreKit.Transaction) throws { +// let purchases = DataStore.shared.purchases +// if let existing: Purchase = self._purchaseByTransactionId(transaction.originalID) { +// existing.revocationDate = transaction.revocationDate +// try purchases.addOrUpdate(instance: existing) +// } +// } - fileprivate func _purchaseById(_ transactionId: UInt64) -> Purchase? { + fileprivate func _purchaseByTransactionId(_ transactionId: UInt64) -> Purchase? { let purchases = DataStore.shared.purchases - return purchases.first(where: { $0.identifier == transactionId }) + return purchases.first(where: { $0.id == transactionId }) } func processTransactionResult(_ result: VerificationResult) async throws -> StoreKit.Transaction { @@ -162,24 +176,24 @@ import LeStorage var currentPlan: StoreItem? { #if DEBUG - return .monthlyUnlimited + return .monthlyUnlimited #elseif TESTFLIGHT - return .monthlyUnlimited + return .monthlyUnlimited #else - if let currentBestPlan = self.currentBestPlan, let plan = StoreItem(rawValue: currentBestPlan.productID) { - return plan - } - return nil + if let currentBestPurchase = self.currentBestPurchase, let plan = StoreItem(rawValue: currentBestPurchase.productId) { + return plan + } + return nil #endif } func userFilteredPurchases() -> [StoreKit.Transaction] { -// Logger.log("self.purchasedTransactions = \(self.purchasedTransactions.count)") + Logger.log("self.purchasedTransactions = \(self.purchasedTransactions.count)") guard let userId = StoreCenter.main.userId, let currentUserUUID: UUID = UUID(uuidString: userId) else { return [] } - let userTransactions = self.purchasedTransactions.filter { currentUserUUID == $0.appAccountToken || $0.offerType == .promotional } + let userTransactions = self.purchasedTransactions.filter { currentUserUUID == $0.appAccountToken || $0.offerType == .code } let now: Date = Date() // print("now = \(now)") @@ -197,17 +211,22 @@ import LeStorage /// Update best plan by filtering Apple purchases with registered purchases by the user fileprivate func _updateBestPlan() { + var purchases: [Purchase] = [] + // Make sure the purchase has been done with the logged user - let userPurchases = self.userFilteredPurchases() + let userPurchases = self.userFilteredPurchases().compactMap { try? $0.purchase() } + purchases.append(contentsOf: userPurchases) - if let unlimited = userPurchases.first(where: { $0.productID == StoreItem.monthlyUnlimited.rawValue }) { - self.currentBestPlan = unlimited - } else if let fivePerMonth = userPurchases.first(where: { $0.productID == StoreItem.fivePerMonth.rawValue }) { - self.currentBestPlan = fivePerMonth - } else { - self.currentBestPlan = nil + let validPurchases = DataStore.shared.purchases.filter { $0.isValid() } + Logger.log("valid purchases = \(validPurchases.count)") + purchases.append(contentsOf: validPurchases) + + if let purchase = purchases.first(where: { $0.productId == StoreItem.monthlyUnlimited.rawValue }) { + self.currentBestPurchase = purchase + } else if let purchase = purchases.first(where: { $0.productId == StoreItem.fivePerMonth.rawValue }) { + self.currentBestPurchase = purchase } - + } fileprivate func _purchasedTournamentCount() -> Int { @@ -227,7 +246,7 @@ import LeStorage case .monthlyUnlimited: return Tournament.TournamentPayment.unlimited case .fivePerMonth: - if let purchaseDate = self.currentBestPlan?.originalPurchaseDate { + if let purchaseDate = self.currentBestPurchase?.purchaseDate { let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.payment == .subscriptionUnit && $0.isCanceled == false } if tournaments.count < StoreItem.five { return Tournament.TournamentPayment.subscriptionUnit @@ -282,11 +301,14 @@ fileprivate extension StoreKit.Transaction { guard let userId = StoreCenter.main.userId else { throw StoreError.missingUserId } + return Purchase(user: userId, - identifier: self.originalID, + transactionId: self.originalID, purchaseDate: self.purchaseDate, productId: self.productID, - quantity: self.purchasedQuantity) + quantity: self.purchasedQuantity, + revocationDate: self.revocationDate, + expirationDate: self.expirationDate) } diff --git a/PadelClub/Views/Tournament/Subscription/Purchase.swift b/PadelClub/Views/Tournament/Subscription/Purchase.swift index 5faafa6..2c13fc2 100644 --- a/PadelClub/Views/Tournament/Subscription/Purchase.swift +++ b/PadelClub/Views/Tournament/Subscription/Purchase.swift @@ -14,21 +14,66 @@ class Purchase: ModelObject, Storable { static func filterByStoreIdentifier() -> Bool { return false } static var relationshipNames: [String] = [] - var id: String = Store.randomId() - + var id: UInt64 var user: String - var identifier: UInt64 var purchaseDate: Date var productId: String var quantity: Int? var revocationDate: Date? = nil + var expirationDate: Date? = nil - init(user: String, identifier: UInt64, purchaseDate: Date, productId: String, quantity: Int? = nil, revocationDate: Date? = nil) { + init(user: String, transactionId: UInt64, purchaseDate: Date, productId: String, quantity: Int? = nil, revocationDate: Date? = nil, expirationDate: Date? = nil) { + self.id = transactionId self.user = user - self.identifier = identifier self.purchaseDate = purchaseDate self.productId = productId self.quantity = quantity + self.revocationDate = revocationDate + self.expirationDate = expirationDate + } + + enum CodingKeys: String, CodingKey, CaseIterable { + case id + case user + case purchaseDate + case productId + case quantity + case revocationDate + case expirationDate + } + + func isValid() -> Bool { + guard self.revocationDate == nil else { + return false + } + guard let expiration = self.expirationDate else { + return false + } + return expiration > Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.id, forKey: .id) + try container.encodeAndEncryptIfPresent(self.user.data(using: .utf8), forKey: .user) + try container.encode(self.purchaseDate, forKey: .purchaseDate) + try container.encode(self.productId, forKey: .productId) + try container.encodeIfPresent(self.quantity, forKey: .quantity) + try container.encodeIfPresent(self.revocationDate, forKey: .revocationDate) + try container.encodeIfPresent(self.expirationDate, forKey: .expirationDate) + } + + required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UInt64.self, forKey: .id) + self.user = try container.decodeEncrypted(key: .user) + self.purchaseDate = try container.decode(Date.self, forKey: .purchaseDate) + self.productId = try container.decode(String.self, forKey: .productId) + self.quantity = try container.decode(Int.self, forKey: .quantity) + self.revocationDate = try container.decodeIfPresent(Date.self, forKey: .revocationDate) + self.expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) } } diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index 71591b9..64fae84 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -15,7 +15,7 @@ final class ServerDataTests: XCTestCase { let password: String = "MyPass1234--" override func setUpWithError() throws { - Store.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" + StoreCenter.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" Task { do { try await self.login() @@ -31,7 +31,7 @@ final class ServerDataTests: XCTestCase { func login() async throws { // print("LOGIN!") - let _: User = try await Store.main.service().login(username: self.username, password: self.password) + let _: User = try await StoreCenter.main.service().login(username: self.username, password: self.password) } func testClub() async throws { @@ -47,7 +47,7 @@ final class ServerDataTests: XCTestCase { club.phone = "061234567890" club.courtCount = 3 - let inserted_club: Club = try await Store.main.service().post(club) + let inserted_club: Club = try await StoreCenter.main.service().post(club) assert(inserted_club.name == club.name) assert(inserted_club.acronym == club.acronym) assert(inserted_club.zipCode == club.zipCode) @@ -60,13 +60,13 @@ final class ServerDataTests: XCTestCase { inserted_club.phone = "123456" - let updated_club: Club = try await Store.main.service().put(inserted_club) + let updated_club: Club = try await StoreCenter.main.service().put(inserted_club) assert(updated_club.phone == inserted_club.phone) } func testLogin() async throws { - let user: User = try await Store.main.service().login(username: self.username, password: self.password) + let user: User = try await StoreCenter.main.service().login(username: self.username, password: self.password) assert(user.username == "test") } @@ -77,14 +77,14 @@ final class ServerDataTests: XCTestCase { return } - let clubs: [Club] = try await Store.main.service().get() + let clubs: [Club] = try await StoreCenter.main.service().get() guard let clubId = clubs.first?.id else { assertionFailure("missing club in database") return } let event = Event(creator: userId, club: clubId, name: "Roland Garros", tenupId: "abc") - let e = try await Store.main.service().post(event) + let e = try await StoreCenter.main.service().post(event) assert(e.name == event.name) assert(e.tenupId == event.tenupId) @@ -93,14 +93,14 @@ final class ServerDataTests: XCTestCase { func testTournament() async throws { - let event: [Event] = try await Store.main.service().get() + let event: [Event] = try await StoreCenter.main.service().get() guard let eventId = event.first?.id else { assertionFailure("missing event in database") return } let tournament = Tournament(event: eventId, name: "RG Homme", startDate: Date(), endDate: nil, creationDate: Date(), isPrivate: false, groupStageFormat: MatchFormat.megaTie, roundFormat: MatchFormat.nineGames, loserRoundFormat: MatchFormat.nineGamesDecisivePoint, groupStageSortMode: GroupStageOrderingMode.snake, groupStageCount: 2, rankSourceDate: Date(), dayDuration: 5, teamCount: 3, teamSorting: TeamSortingType.rank, federalCategory: TournamentCategory.mix, federalLevelCategory: TournamentLevel.p1000, federalAgeCategory: FederalTournamentAge.a45, closedRegistrationDate: Date(), groupStageAdditionalQualified: 4, courtCount: 9, prioritizeClubMembers: true, qualifiedPerGroupStage: 1, teamsPerGroupStage: 2, entryFee: 30.0, additionalEstimationDuration: 5, isDeleted: true, publishTeams: true, publishSummons: true, publishGroupStages: true, publishBrackets: true, shouldVerifyBracket: true, shouldVerifyGroupStage: true, hideTeamsWeight: true, publishTournament: true, hidePointsEarned: true, publishRankings: true) - let t = try await Store.main.service().post(tournament) + let t = try await StoreCenter.main.service().post(tournament) assert(t.event == tournament.event) assert(t.name == tournament.name) @@ -143,14 +143,14 @@ final class ServerDataTests: XCTestCase { func testGroupStage() async throws { - let tournament: [Tournament] = try await Store.main.service().get() + let tournament: [Tournament] = try await StoreCenter.main.service().get() guard let tournamentId = tournament.first?.id else { assertionFailure("missing tournament in database") return } let groupStage = GroupStage(tournament: tournamentId, index: 2, size: 3, matchFormat: MatchFormat.nineGames, startDate: Date(), name: "Yeah!") - let gs: GroupStage = try await Store.main.service().post(groupStage) + let gs: GroupStage = try await StoreCenter.main.service().post(groupStage) assert(gs.tournament == groupStage.tournament) assert(gs.name == groupStage.name) @@ -163,16 +163,16 @@ final class ServerDataTests: XCTestCase { func testRound() async throws { - let tournament: [Tournament] = try await Store.main.service().get() + let tournament: [Tournament] = try await StoreCenter.main.service().get() guard let tournamentId = tournament.first?.id else { assertionFailure("missing tournament in database") return } - let rounds: [Round] = try await Store.main.service().get() + let rounds: [Round] = try await StoreCenter.main.service().get() let parentRoundId = rounds.first?.id let round = Round(tournament: tournamentId, index: 1, parent: parentRoundId, matchFormat: MatchFormat.nineGames, startDate: Date()) - let r: Round = try await Store.main.service().post(round) + let r: Round = try await StoreCenter.main.service().post(round) assert(r.tournament == round.tournament) assert(r.index == round.index) @@ -184,12 +184,12 @@ final class ServerDataTests: XCTestCase { func testTeamRegistration() async throws { - let tournament: [Tournament] = try await Store.main.service().get() + let tournament: [Tournament] = try await StoreCenter.main.service().get() guard let tournamentId = tournament.first?.id else { assertionFailure("missing tournament in database") return } - let groupStages: [GroupStage] = try await Store.main.service().get() + let groupStages: [GroupStage] = try await StoreCenter.main.service().get() guard let groupStageId = groupStages.first?.id else { assertionFailure("missing groupStage in database") return @@ -197,7 +197,7 @@ final class ServerDataTests: XCTestCase { let teamRegistration = TeamRegistration(tournament: tournamentId, groupStage: groupStageId, registrationDate: Date(), callDate: Date(), bracketPosition: 1, groupStagePosition: 2, comment: "comment", source: "source", sourceValue: "source V", logo: "logo", name: "Stax", walkOut: true, wildCardBracket: true, wildCardGroupStage: true, weight: 1, lockedWeight: 11, confirmationDate: Date(), qualified: true) - let tr: TeamRegistration = try await Store.main.service().post(teamRegistration) + let tr: TeamRegistration = try await StoreCenter.main.service().post(teamRegistration) assert(tr.tournament == teamRegistration.tournament) assert(tr.groupStage == teamRegistration.groupStage) @@ -222,14 +222,14 @@ final class ServerDataTests: XCTestCase { func testPlayerRegistration() async throws { - let teamRegistrations: [TeamRegistration] = try await Store.main.service().get() + let teamRegistrations: [TeamRegistration] = try await StoreCenter.main.service().get() guard let teamRegistrationId = teamRegistrations.first?.id else { assertionFailure("missing teamRegistrations in database") return } let playerRegistration = PlayerRegistration(teamRegistration: teamRegistrationId, firstName: "juan", lastName: "lebron", licenceId: "123", rank: 11, paymentType: PlayerRegistration.PlayerPaymentType.cash, sex: PlayerRegistration.PlayerSexType.male, tournamentPlayed: 2, points: 33, clubName: "le club", ligueName: "la league", assimilation: "ass", phoneNumber: "123123", email: "email@email.com", birthdate: nil, computedRank: 222, source: PlayerRegistration.PlayerDataSource.frenchFederation, hasArrived: true) - let pr: PlayerRegistration = try await Store.main.service().post(playerRegistration) + let pr: PlayerRegistration = try await StoreCenter.main.service().post(playerRegistration) assert(pr.teamRegistration == playerRegistration.teamRegistration) assert(pr.firstName == playerRegistration.firstName) @@ -253,16 +253,16 @@ final class ServerDataTests: XCTestCase { func testMatch() async throws { - let teamRegistrations: [TeamRegistration] = try await Store.main.service().get() + let teamRegistrations: [TeamRegistration] = try await StoreCenter.main.service().get() guard let teamRegistrationId = teamRegistrations.first?.id else { assertionFailure("missing teamRegistrations in database") return } - let rounds: [Round] = try await Store.main.service().get() + let rounds: [Round] = try await StoreCenter.main.service().get() let parentRoundId = rounds.first?.id let match: Match = Match(round: parentRoundId, groupStage: nil, startDate: Date(), endDate: Date(), index: 2, matchFormat: MatchFormat.twoSets, servingTeamId: teamRegistrationId, winningTeamId: teamRegistrationId, losingTeamId: teamRegistrationId, disabled: true, courtIndex: 1, confirmed: true) - let m: Match = try await Store.main.service().post(match) + let m: Match = try await StoreCenter.main.service().post(match) assert(m.round == match.round) assert(m.groupStage == match.groupStage) @@ -281,18 +281,18 @@ final class ServerDataTests: XCTestCase { func testTeamScore() async throws { - let matches: [Match] = try await Store.main.service().get() + let matches: [Match] = try await StoreCenter.main.service().get() guard let matchId = matches.first?.id else { assertionFailure("missing match in database") return } - let teamRegistrations: [TeamRegistration] = try await Store.main.service().get() + let teamRegistrations: [TeamRegistration] = try await StoreCenter.main.service().get() guard let teamRegistrationId = teamRegistrations.first?.id else { assertionFailure("missing teamRegistrations in database") return } let teamScore = TeamScore(match: matchId, teamRegistration: teamRegistrationId, score: "6/6", walkOut: 1, luckyLoser: 1) - let ts: TeamScore = try await Store.main.service().post(teamScore) + let ts: TeamScore = try await StoreCenter.main.service().post(teamScore) assert(ts.match == teamScore.match) assert(ts.teamRegistration == teamScore.teamRegistration) @@ -304,14 +304,14 @@ final class ServerDataTests: XCTestCase { func testCourt() async throws { - let clubs: [Club] = try await Store.main.service().get() + let clubs: [Club] = try await StoreCenter.main.service().get() guard let clubId = clubs.first?.id else { assertionFailure("missing club in database") return } let court = Court(index: 1, club: clubId, name: "Philippe Chatrier", exitAllowed: true, indoor: true) - let c: Court = try await Store.main.service().post(court) + let c: Court = try await StoreCenter.main.service().post(court) assert(c.club == court.club) assert(c.name == court.name) @@ -323,14 +323,14 @@ final class ServerDataTests: XCTestCase { func testDateInterval() async throws { - let event: [Event] = try await Store.main.service().get() + let event: [Event] = try await StoreCenter.main.service().get() guard let eventId = event.first?.id else { assertionFailure("missing event in database") return } let dateInterval = DateInterval(event: eventId, courtIndex: 1, startDate: Date(), endDate: Date()) - let di: PadelClub.DateInterval = try await Store.main.service().post(dateInterval) + let di: PadelClub.DateInterval = try await StoreCenter.main.service().post(dateInterval) assert(di.event == dateInterval.event) assert(di.courtIndex == dateInterval.courtIndex) @@ -346,16 +346,19 @@ final class ServerDataTests: XCTestCase { return } - let purchase: Purchase = Purchase(user: userId, identifier: 1234, purchaseDate: Date(), productId: "productId", quantity: 3, revocationDate: Date()) - let p: Purchase = try await Store.main.service().post(purchase) + let transactionId = UInt64.random(in: 0...100000) + let quantity = Int.random(in: 0...10) + + let purchase: Purchase = Purchase(user: userId, transactionId: transactionId, purchaseDate: Date(), productId: "app.padelclub.productId", quantity: quantity, revocationDate: Date(), expirationDate: Date()) + let p: Purchase = try await StoreCenter.main.service().post(purchase) assert(p.id == purchase.id) - assert(p.identifier == purchase.identifier) + assert(p.user == purchase.user) assert(p.productId == purchase.productId) assert(p.purchaseDate.formatted() == purchase.purchaseDate.formatted()) assert(p.quantity == purchase.quantity) - assert(p.user == purchase.user) assert(p.revocationDate?.formatted() == purchase.revocationDate?.formatted()) + assert(p.expirationDate?.formatted() == purchase.expirationDate?.formatted()) } diff --git a/PadelClubTests/TokenExemptionTests.swift b/PadelClubTests/TokenExemptionTests.swift index 61831eb..923be80 100644 --- a/PadelClubTests/TokenExemptionTests.swift +++ b/PadelClubTests/TokenExemptionTests.swift @@ -16,8 +16,8 @@ final class TokenExemptionTests: XCTestCase { let password: String = "MyPass1234--" override func setUpWithError() throws { - Store.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" - Store.main.disconnect() + StoreCenter.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" + StoreCenter.main.disconnect() } override func tearDownWithError() throws { @@ -27,15 +27,15 @@ final class TokenExemptionTests: XCTestCase { func testClubCreation() async throws { let user = try await self.login() - Store.main.disconnect() + StoreCenter.main.disconnect() let club: Club = Club(name: "mon club 2", acronym: "MC", phone: "132", code: "456", address: "l'adresse", city: "la ville", zipCode: "13131", latitude: 13.11111, longitude: 1.121212) - let c = try await Store.main.service().post(club) + let c = try await StoreCenter.main.service().post(club) assert(c.id == club.id) do { - _ = try await Store.main.service().put(club) + _ = try await StoreCenter.main.service().put(club) assertionFailure("the request above should fail without an authenticated user") } catch { // good stuff @@ -44,13 +44,13 @@ final class TokenExemptionTests: XCTestCase { let _ = try await self.login() club.creator = user.id - let uc = try await Store.main.service().put(club) + let uc = try await StoreCenter.main.service().put(club) assert(uc.creator == user.id) } func login() async throws -> User { - let user: User = try await Store.main.service().login(username: self.username, password: self.password) + let user: User = try await StoreCenter.main.service().login(username: self.username, password: self.password) return user } diff --git a/PadelClubTests/UserDataTests.swift b/PadelClubTests/UserDataTests.swift index 21ef9d0..51e6235 100644 --- a/PadelClubTests/UserDataTests.swift +++ b/PadelClubTests/UserDataTests.swift @@ -15,7 +15,7 @@ final class UserDataTests: XCTestCase { let password: String = "MyPass1234--" override func setUpWithError() throws { - Store.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" + StoreCenter.main.synchronizationApiURL = "http://127.0.0.1:8000/roads/" } override func tearDownWithError() throws { @@ -24,8 +24,8 @@ final class UserDataTests: XCTestCase { func testUserCreation() async throws { - let userCreationForm = UserCreationForm(user: User.placeHolder(), username: self.username, password: self.password, firstName: "jean", lastName: "coco", email: "laurent@staxriver.com", phone: "0123", country: "France") - let user: User = try await Store.main.service().createAccount(user: userCreationForm) + let userCreationForm = UserCreationForm(user: User.placeHolder(), username: self.username, password: self.password, firstName: "jean", lastName: "coco", email: "test@lolomo.com", phone: "0123", country: "France") + let user: User = try await StoreCenter.main.service().createAccount(user: userCreationForm) assert(user.username == userCreationForm.username) assert(user.firstName == userCreationForm.firstName) @@ -37,7 +37,7 @@ final class UserDataTests: XCTestCase { } func login() async throws -> User { - let user: User = try await Store.main.service().login(username: self.username, password: self.password) + let user: User = try await StoreCenter.main.service().login(username: self.username, password: self.password) return user } @@ -56,7 +56,7 @@ final class UserDataTests: XCTestCase { user.groupStageMatchFormatPreference = MatchFormat.twoSets user.loserBracketMatchFormatPreference = MatchFormat.twoSetsOfFourGames - let uu = try await Store.main.service().put(user) + let uu = try await StoreCenter.main.service().put(user) assert(uu.summonsMessageBody == user.summonsMessageBody) assert(uu.summonsMessageSignature == user.summonsMessageSignature)