Subscriptions work in progress

multistore
Laurent 2 years ago
parent 21e1417bf4
commit 1356ecf71d
  1. 4
      PadelClub.xcodeproj/project.pbxproj
  2. 5
      PadelClub/Data/Tournament.swift
  3. 98
      PadelClub/Views/Subscription/Guard.swift
  4. 28
      PadelClub/Views/Subscription/Purchase.swift
  5. 10
      PadelClub/Views/Subscription/StoreItem.swift
  6. 2
      PadelClub/Views/Subscription/StoreManager.swift
  7. 2
      PadelClub/Views/Subscription/SubscriptionView.swift
  8. 4
      PadelClub/Views/User/ChangePasswordView.swift
  9. 7
      PadelClub/Views/User/LoginView.swift
  10. 5
      PadelClub/Views/User/UserCreationView.swift

@ -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 = "<group>"; };
C44B79102BBDA63A00906534 /* Locale+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Extensions.swift"; sourceTree = "<group>"; };
C45BAE3A2BC6DF10002EEC8A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
C45BAE432BCA753E002EEC8A /* Purchase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Purchase.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>"; };
@ -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 */,

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

@ -20,6 +20,8 @@ import LeStorage
var updateListenerTask: Task<Void, Error>? = 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)
}
}

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

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

@ -9,7 +9,7 @@ import Foundation
import StoreKit
import LeStorage
public enum StoreError: Error {
public enum StoreManagerError: Error {
case failedVerification
}

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

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

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

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

Loading…
Cancel
Save