Work on payments

multistore
Laurent 2 years ago
parent 4fe9743a32
commit 3da45f62db
  1. 30
      PadelClub.xcodeproj/project.pbxproj
  2. 2
      PadelClub/Data/MockData.swift
  3. 131
      PadelClub/Data/Tournament.swift
  4. 47
      PadelClub/Extensions/String+Crypto.swift
  5. 12
      PadelClub/Manager/Key.swift
  6. 1
      PadelClub/ViewModel/FederalDataViewModel.swift
  7. 4
      PadelClub/Views/Calling/CallView.swift
  8. 1
      PadelClub/Views/Calling/Components/MenuWarningView.swift
  9. 1
      PadelClub/Views/Calling/SendToAllView.swift
  10. 24
      PadelClub/Views/Match/MatchDetailView.swift
  11. 3
      PadelClub/Views/Player/Components/EditablePlayerView.swift
  12. 8
      PadelClub/Views/Subscription/Guard.swift
  13. 46
      PadelClubTests/PaymentTests.swift

@ -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 = "<group>"; };
C49EF0252BD80AE80077B5AA /* SubscriptionInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInfoView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C49EF03D2BE160720077B5AA /* Key.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Key.swift; sourceTree = "<group>"; };
C49EF0412BE23BF50077B5AA /* PaymentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTests.swift; sourceTree = "<group>"; };
C4A47D592B6D383C00ADC637 /* Tournament.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tournament.swift; sourceTree = "<group>"; };
C4A47D5D2B6D38EC00ADC637 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
C4A47D622B6D3D6500ADC637 /* Club.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Club.swift; sourceTree = "<group>"; };
@ -634,6 +640,7 @@
isa = PBXGroup;
children = (
C425D4112B6D249E002A7B48 /* PadelClubTests.swift */,
C49EF0412BE23BF50077B5AA /* PaymentTests.swift */,
);
path = PadelClubTests;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
@ -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;

@ -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)
}
}

@ -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..<max)) }
sequence.append(payment.rawValue)
sequence.append(contentsOf: (1...13).map { _ in Int.random(in: (0..<max ))} )
let stringCombo: [String] = sequence.map { $0.formatted() }
let joined: String = stringCombo.joined(separator: "")
if let data = joined.data(using: .utf8) {
do {
self.payment = try data.encrypt(pass: Key.pass.rawValue)
} catch {
Logger.error(error)
}
}
}
var currentPayment: TournamentPayment? {
if let current = self._currentPayment {
return current
}
self._currentPayment = self.decryptPayment()
return self._currentPayment
}
func decryptPayment() -> 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..<max)) }
sequence.append(canceled.encodedValue)
sequence.append(contentsOf: (1...13).map { _ in Int.random(in: (0..<max ))} )
let stringCombo: [String] = sequence.map { $0.formatted() }
let joined: String = stringCombo.joined(separator: "")
if let data = joined.data(using: .utf8) {
do {
self.isCanceled = try data.encrypt(pass: Key.pass.rawValue)
} catch {
Logger.error(error)
}
}
}
var currentCanceled: Bool? {
if let current = self._currentCanceled {
return current
}
self._currentCanceled = self.decryptCanceled()
return self._currentCanceled
}
func decryptCanceled() -> 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"
}
}

@ -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)
}
}

@ -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="
}

@ -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))

@ -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 {

@ -23,6 +23,7 @@ struct MenuWarningView: View {
return nil
}
// TODO: Guard
@ViewBuilder
private func _actionView(players: [PlayerRegistration], privateMode: Bool = false) -> some View {
Button("Message") {

@ -26,6 +26,7 @@ struct SendToAllView: View {
}
}
}
// TODO: Guard
var body: some View {
NavigationStack {

@ -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<Bool> {
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

@ -31,7 +31,8 @@ struct EditablePlayerView: View {
}
}
}
// TODO: Guard
@ViewBuilder
func computedPlayerView(_ player: PlayerRegistration) -> some View {
VStack(alignment: .leading) {

@ -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

@ -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)
}
}
Loading…
Cancel
Save