WIP on subscriptions and payments

multistore
Laurent 2 years ago
parent b68a7e5513
commit 70a0246eda
  1. 10
      PadelClub/Data/DataStore.swift
  2. 1
      PadelClub/Data/README.md
  3. 21
      PadelClub/Views/Navigation/Umpire/UmpireView.swift
  4. 75
      PadelClub/Views/Subscription/Guard.swift
  5. 3
      PadelClub/Views/Subscription/Purchase.swift
  6. 2
      PadelClub/Views/Subscription/StoreItem.swift
  7. 20
      PadelClub/Views/Subscription/StoreManager.swift
  8. 25
      PadelClub/Views/Subscription/SubscriptionView.swift
  9. 20
      PadelClubTests/ServerDataTests.swift

@ -59,6 +59,8 @@ class DataStore: ObservableObject {
store.synchronizationApiURL = "https://xlr.alwaysdata.net/api/"
var synchronized : Bool = true
_ = Guard.main // init
#if DEBUG
if let server = PListReader.readString(plist: "local", key: "server") {
store.synchronizationApiURL = server
@ -105,13 +107,19 @@ class DataStore: ObservableObject {
}
@objc func collectionDidLoad(notification: Notification) {
self.objectWillChange.send()
DispatchQueue.main.async {
self.objectWillChange.send()
}
if let userSingleton: StoredSingleton<User> = notification.object as? StoredSingleton<User> {
self.user = userSingleton.item() ?? self._temporaryLocalUser.item ?? User.placeHolder()
} else if let clubsCollection: StoredCollection<Club> = notification.object as? StoredCollection<Club> {
self._fixMissingClubCreatorIfNecessary(clubsCollection)
} else if let eventsCollection: StoredCollection<Event> = notification.object as? StoredCollection<Event> {
self._fixMissingEventCreatorIfNecessary(eventsCollection)
} else if let purchaseCollection: StoredCollection<Purchase> = notification.object as? StoredCollection<Purchase> {
try? purchaseCollection.deleteAll()
}
}

@ -6,6 +6,7 @@ Dans Swift:
- Ajouter la codingKey correspondante
- Pour la classe **Tournament**, ajouter le champ dans l'encoding/decoding
- Ouvrir **ServerDataTests** et ajouter un test sur le champ
- Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple
Dans Django:
- Ajouter le champ dans la classe

@ -10,9 +10,12 @@ import CoreLocation
import LeStorage
struct UmpireView: View {
@Environment(NavigationViewModel.self) private var navigation: NavigationViewModel
@EnvironmentObject var dataStore: DataStore
@State private var presentSearchView: Bool = false
@State private var showSubscriptions: Bool = false
var lastDataSource: String? {
dataStore.appSettings.lastDataSource
@ -35,13 +38,22 @@ struct UmpireView: View {
PurchaseListView()
Section {
NavigationLink {
SubscriptionView()
Button {
self.showSubscriptions = true
} label: {
Label("Les offres", systemImage: "bookmark.fill")
}
}
// Section {
// NavigationLink {
// SubscriptionView()
// } label: {
// Label("Les offres", systemImage: "bookmark.fill")
// }
// }
NavigationLink {
MainUserView()
} label: {
@ -153,6 +165,11 @@ struct UmpireView: View {
}
#endif
}
.sheet(isPresented: self.$showSubscriptions, content: {
NavigationStack {
SubscriptionView()
}
})
.sheet(isPresented: $presentSearchView) {
let user = dataStore.user
NavigationStack {

@ -86,30 +86,51 @@ import LeStorage
@MainActor
func updatePurchasedIdentifiers(_ transaction: StoreKit.Transaction) async {
if transaction.revocationDate == nil {
// If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`.
purchasedTransactions.insert(transaction)
do {
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)
// if self._purchaseById(transaction.originalID) == nil {
// let purchase: Purchase = transaction.purchase()
// try self.purchases.addOrUpdate(instance: purchase)
// }
} else {
// If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`.
purchasedTransactions.remove(transaction)
try self._updatePurchaseIfPossible(transaction: transaction)
}
} catch {
Logger.error(error)
}
self._updateBestPlan()
}
fileprivate func _addPurchaseIfPossible(transaction: StoreKit.Transaction) throws {
if self.purchases.hasLoadedFromServer {
if self._purchaseById(transaction.originalID) == nil {
let purchase: Purchase = transaction.purchase()
// let json = try? purchase.jsonString() ?? "nope"
// Logger.log("Add or update purchase = \(json) ")
try self.purchases.addOrUpdate(instance: purchase)
} catch {
Logger.error(error)
}
} else {
// If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`.
purchasedTransactions.remove(transaction)
if let existing = self.purchases.first(where: { $0.identifier == transaction.originalID }) {
do {
try self.purchases.delete(instance: existing)
} catch {
Logger.error(error)
}
}
}
fileprivate func _updatePurchaseIfPossible(transaction: StoreKit.Transaction) throws {
if self.purchases.hasLoadedFromServer {
if let existing: Purchase = self._purchaseById(transaction.originalID) {
existing.revocationDate = transaction.revocationDate
try self.purchases.addOrUpdate(instance: existing)
}
}
self._updateBestPlan()
}
fileprivate func _purchaseById(_ transactionId: UInt64) -> Purchase? {
return self.purchases.first(where: { $0.identifier == transactionId })
}
func processTransactionResult(_ result: VerificationResult<StoreKit.Transaction>) async throws -> StoreKit.Transaction {
@ -123,19 +144,19 @@ import LeStorage
}
var currentPlan: StoreItem? {
#if DEBUG
return nil
#else
// #if DEBUG
// return nil
// #else
if let currentBestPlan = self.currentBestPlan, let plan = StoreItem(rawValue: currentBestPlan.productID) {
return plan
}
return nil
#endif
// #endif
}
func userFilteredPurchases() -> [StoreKit.Transaction] {
Logger.log("self.purchasedTransactions = \(self.purchasedTransactions.count)")
guard let currentUserUUID = Store.main.currentUserUUID else {
guard let currentUserUUID: UUID = Store.main.currentUserUUID else {
return []
}
@ -179,18 +200,18 @@ import LeStorage
return Tournament.TournamentPayment.unlimited
case .fivePerMonth:
if let purchaseDate = self.currentBestPlan?.originalPurchaseDate {
let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.isCanceled == false }
let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.payment == .subscriptionUnit && $0.isCanceled == false }
if tournaments.count < StoreItem.five {
return Tournament.TournamentPayment.subscriptionUnit
}
}
return nil
default:
let freelyPayed = DataStore.shared.tournaments.filter { $0.payment == .free && $0.isCanceled == false }.count
let freelyPayed: Int = DataStore.shared.tournaments.filter { $0.payment == .free && $0.isCanceled == false }.count
if freelyPayed < 1 {
return Tournament.TournamentPayment.free
}
let tournamentCreditCount = self._purchasedTournamentCount()
let tournamentCreditCount: Int = self._purchasedTournamentCount()
let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == .unit && $0.isCanceled == false }.count
if tournamentCreditCount > unitlyPayed {
return Tournament.TournamentPayment.unit

@ -20,8 +20,9 @@ class Purchase: ModelObject, Storable {
var purchaseDate: Date
var productId: String
var quantity: Int?
var revocationDate: Date? = nil
init(user: String, identifier: UInt64, purchaseDate: Date, productId: String, quantity: Int? = nil) {
init(user: String, identifier: UInt64, purchaseDate: Date, productId: String, quantity: Int? = nil, revocationDate: Date? = nil) {
self.user = user
self.identifier = identifier
self.purchaseDate = purchaseDate

@ -12,7 +12,7 @@ enum StoreItem: String, Identifiable, CaseIterable {
case fivePerMonth = "app.padelclub.tournament.subscription.five.per.month"
case unit = "app.padelclub.tournament.unit"
static let five: Int = 5
static let five: Int = 2
var id: String { return self.rawValue }

@ -49,10 +49,9 @@ class StoreManager {
@MainActor
func requestProducts() async {
do {
let identifiers: [String] = StoreItem.allCases.map { $0.rawValue }
Logger.log("Request products: \(identifiers)")
// let identifiers: [String] = StoreItem.allCases.map { $0.rawValue }
var products: [Product] = try await Product.products(for: identifiers)
var products: [Product] = try await Product.products(for: self._productIdentifiers())
products = products.sorted { p1, p2 in
return p2.price > p1.price
}
@ -65,6 +64,19 @@ class StoreManager {
}
}
fileprivate func _productIdentifiers() -> [String] {
var items: [StoreItem] = []
switch Guard.main.currentPlan {
case .fivePerMonth:
items = [StoreItem.unit, StoreItem.monthlyUnlimited]
case .monthlyUnlimited:
break
default:
items = StoreItem.allCases
}
return items.map { $0.rawValue }
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions which didn't come from a direct call to `purchase()`.
@ -106,7 +118,7 @@ class StoreManager {
case .success(let verificationResult):
let transaction = try await Guard.main.processTransactionResult(verificationResult)
// Always finish a transaction.
await transaction.finish()

@ -38,6 +38,7 @@ class SubscriptionModel: ObservableObject, StoreDelegate {
@Published var error: Error? = nil
@Published var isLoading: Bool = false
@Published var isPurchasing: Bool = false
@Published var selectedProduct: Product? = nil {
didSet {
self._computePrice()
@ -61,7 +62,6 @@ class SubscriptionModel: ObservableObject, StoreDelegate {
}
func productsReceived(products: [Product]) {
self.isLoading = false
self.products = products
Logger.log("products received = \(products.count)")
@ -78,17 +78,22 @@ class SubscriptionModel: ObservableObject, StoreDelegate {
Logger.w("missing product or store manager")
return
}
self.isPurchasing = true
Task {
do {
if product.item.isConsumable {
if let _ = try await storeManager.purchase(product, quantity: self.quantity) {
self.isPurchasing = false
self.showSuccessfulPurchaseView = true
}
} else {
let _ = try await storeManager.purchase(product)
self.isPurchasing = false
}
} catch {
Logger.error(error)
self.isPurchasing = false
}
}
}
@ -173,11 +178,19 @@ struct SubscriptionView: View {
}
} label: {
HStack {
Text("Acheter")
Spacer()
Text(self.model.totalPrice)
}.padding(8.0)
.fontWeight(.bold)
if self.model.isPurchasing {
Spacer()
ProgressView().tint(.white)
Spacer()
} else {
Text("Acheter")
Spacer()
Text(self.model.totalPrice)
}
}
.padding(8.0)
.fontWeight(.bold)
}
.buttonStyle(.borderedProminent)
.tint(.orange)

@ -334,4 +334,24 @@ final class ServerDataTests: XCTestCase {
}
func testPurchase() async throws {
guard let userId = Store.main.currentUserUUID?.uuidString.lowercased() else {
assertionFailure("missing user UUID")
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)
assert(p.id == purchase.id)
assert(p.identifier == purchase.identifier)
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())
}
}

Loading…
Cancel
Save