From 4560ebb30a5e7805c7b1cafbbdb9e6bc99999c87 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Thu, 29 May 2025 07:13:10 +0200 Subject: [PATCH] fix issue with stripe account onboarding --- PadelClub.xcodeproj/project.pbxproj | 12 +- .../Network/StripeValidationService.swift | 157 +++++++++++++++++- .../Screen/RegistrationSetupView.swift | 149 ++++++++++++----- 3 files changed, 259 insertions(+), 59 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 68556a1..8bcced7 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3120,7 +3120,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3166,7 +3166,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3285,7 +3285,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3330,7 +3330,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3374,7 +3374,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3416,7 +3416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.31; + MARKETING_VERSION = 1.2.32; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Utils/Network/StripeValidationService.swift b/PadelClub/Utils/Network/StripeValidationService.swift index dce0266..e0548da 100644 --- a/PadelClub/Utils/Network/StripeValidationService.swift +++ b/PadelClub/Utils/Network/StripeValidationService.swift @@ -10,12 +10,16 @@ import LeStorage class StripeValidationService { - static func validateStripeAccountID(_ accountID: String) async throws -> ValidationResponse { - let service = try StoreCenter.main.service() + // MARK: - Validate Stripe Account + static func validateStripeAccount(accountId: String) async throws -> ValidationResponse { + let service = try StoreCenter.main.service() var urlRequest = try service._baseRequest(servicePath: "validate-stripe-account/", method: .post, requiresToken: true) - let body = ["account_id": accountID] - urlRequest.httpBody = try JSONEncoder().encode(body) + var body: [String: Any] = [:] + + body["account_id"] = accountId + + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) do { let (data, response) = try await URLSession.shared.data(for: urlRequest) @@ -23,17 +27,79 @@ class StripeValidationService { guard let httpResponse = response as? HTTPURLResponse else { throw ValidationError.invalidResponse } + switch httpResponse.statusCode { case 200...299: let decodedResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) return decodedResponse - case 400: - // Handle bad request + case 400, 403, 404: + // Handle client errors - still decode as ValidationResponse let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) return errorResponse - case 403: - // Handle permission error - let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) + default: + throw ValidationError.invalidResponse + } + } catch let error as ValidationError { + throw error + } catch { + throw ValidationError.networkError(error) + } + } + + // MARK: - Create Stripe Connect Account + static func createStripeConnectAccount() async throws -> CreateAccountResponse { + let service = try StoreCenter.main.service() + let urlRequest = try service._baseRequest(servicePath: "stripe/create-account/", method: .post, requiresToken: true) + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ValidationError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + let decodedResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) + return decodedResponse + case 400, 403, 404: + let errorResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) + return errorResponse + default: + throw ValidationError.invalidResponse + } + } catch let error as ValidationError { + throw error + } catch { + throw ValidationError.networkError(error) + } + } + + // MARK: - Create Stripe Account Link + static func createStripeAccountLink(_ accountId: String? = nil) async throws -> CreateLinkResponse { + let service = try StoreCenter.main.service() + var urlRequest = try service._baseRequest(servicePath: "stripe/create-account-link/", method: .post, requiresToken: true) + + var body: [String: Any] = [:] + + if let accountId = accountId { + body["account_id"] = accountId + } + + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ValidationError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + let decodedResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) + return decodedResponse + case 400, 403, 404: + let errorResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) return errorResponse default: throw ValidationError.invalidResponse @@ -46,17 +112,67 @@ class StripeValidationService { } } +// MARK: - Response Models + struct ValidationResponse: Codable { let valid: Bool + let canProcessPayments: Bool? + let onboardingComplete: Bool? + let needsOnboarding: Bool? let account: AccountDetails? let error: String? + + enum CodingKeys: String, CodingKey { + case valid + case canProcessPayments = "can_process_payments" + case onboardingComplete = "onboarding_complete" + case needsOnboarding = "needs_onboarding" + case account + case error + } } struct AccountDetails: Codable { let id: String + let chargesEnabled: Bool? + let payoutsEnabled: Bool? + let detailsSubmitted: Bool? enum CodingKeys: String, CodingKey { case id + case chargesEnabled = "charges_enabled" + case payoutsEnabled = "payouts_enabled" + case detailsSubmitted = "details_submitted" + } +} + +struct CreateAccountResponse: Codable { + let success: Bool + let accountId: String? + let message: String? + let existing: Bool? + let error: String? + + enum CodingKeys: String, CodingKey { + case success + case accountId = "account_id" + case message + case existing + case error + } +} + +struct CreateLinkResponse: Codable { + let success: Bool + let url: URL? + let accountId: String? + let error: String? + + enum CodingKeys: String, CodingKey { + case success + case url + case accountId = "account_id" + case error } } @@ -64,5 +180,28 @@ enum ValidationError: Error { case invalidResponse case networkError(Error) case invalidData + case encodingError + case urlNotFound + case accountNotFound + case onlinePaymentNotEnabled + + var localizedDescription: String { + switch self { + case .invalidResponse: + return "Réponse du serveur invalide" + case .networkError(let error): + return "Erreur réseau : \(error.localizedDescription)" + case .invalidData: + return "Données reçues invalides" + case .encodingError: + return "Échec de l'encodage des données de la requête" + case .accountNotFound: + return "Le compte n'a pas pu être généré" + case .urlNotFound: + return "Le lien pour utiliser un compte stripe n'a pas pu être généré" + case .onlinePaymentNotEnabled: + return "Le paiement en ligne n'a pas pu être activé pour ce tournoi" + } + } } diff --git a/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift b/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift index 07a713c..4b51a39 100644 --- a/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift +++ b/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift @@ -10,6 +10,7 @@ import SwiftUI import PadelClubData struct RegistrationSetupView: View { + @Environment(\.openURL) private var openURL @EnvironmentObject var dataStore: DataStore @Bindable var tournament: Tournament @State private var enableOnlineRegistration: Bool @@ -41,7 +42,7 @@ struct RegistrationSetupView: View { @State private var refundDateLimit: Date @State private var refundDateLimitEnabled: Bool @State private var stripeAccountId: String - @State private var stripeAccountIdIsInvalid: Bool = false + @State private var stripeAccountIdIsInvalid: Bool? @State private var paymentConfig: PaymentConfig? @State private var timeToConfirmConfig: TimeToConfirmConfig? @@ -50,6 +51,10 @@ struct RegistrationSetupView: View { @State private var hasChanges: Bool = false + @State private var stripeOnBoardingURL: URL? = nil + @State private var errorMessage: String? = nil + @State private var presentErrorAlert: Bool = false + @Environment(\.dismiss) private var dismiss init(tournament: Tournament) { @@ -320,7 +325,6 @@ struct RegistrationSetupView: View { ToolbarItem(placement: .topBarTrailing) { ButtonValidateView(role: .destructive) { _save() - dismiss() } } } @@ -331,7 +335,7 @@ struct RegistrationSetupView: View { HStack { Button("Effacer") { stripeAccountId = "" - stripeAccountIdIsInvalid = false + stripeAccountIdIsInvalid = nil tournament.stripeAccountId = nil } .buttonStyle(.borderless) @@ -344,7 +348,13 @@ struct RegistrationSetupView: View { } } } - + .alert("Paiement en ligne", isPresented: $presentErrorAlert, actions: { + Button("Fermer") { + self.presentErrorAlert = false + } + }, message: { + Text(ValidationError.onlinePaymentNotEnabled.localizedDescription) + }) .toolbarRole(.editor) .headerProminence(.increased) .navigationTitle("Inscription en ligne") @@ -435,32 +445,6 @@ struct RegistrationSetupView: View { Text("Revenu Padel Club") } } - - if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { - VStack(alignment: .leading) { - LabeledContent { - if isValidating { - ProgressView() - } else if focusedField == nil, stripeAccountIdIsInvalid == false, stripeAccountId.isEmpty == false, isValidating == false { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - } - } label: { - TextField("Identifiant du compte Stripe", text: $stripeAccountId) - .frame(maxWidth: .infinity) - .focused($focusedField, equals: ._stripeAccountId) - .disabled(isValidating) - .keyboardType(.alphabet) - .textContentType(nil) - .autocorrectionDisabled() - } - if stripeAccountIdIsInvalid { - Text("Identifiant Stripe invalide. Vous ne pouvez pas activer le paiement en ligne.").foregroundStyle(.logoRed) - Button("Ré-essayer") { - _confirmStripeAccountId() - } - } - } - } } header: { Text("Paiement en ligne") } footer: { @@ -488,13 +472,81 @@ struct RegistrationSetupView: View { .onChange(of: refundDateLimit) { _hasChanged() } - .onChange(of: focusedField) { old, new in - if old == ._stripeAccountId { - _confirmStripeAccountId() - } - } - if dataStore.user.registrationPaymentMode.requiresStripe() { + if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { + Section { + LabeledContent { + if isValidating { + ProgressView() + } else if focusedField == nil, stripeAccountIdIsInvalid == false, stripeAccountId.isEmpty == false, isValidating == false { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } label: { + TextField("Identifiant du compte Stripe", text: $stripeAccountId) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._stripeAccountId) + .disabled(isValidating) + .keyboardType(.alphabet) + .textContentType(nil) + .autocorrectionDisabled() + } + .onChange(of: focusedField) { old, new in + if old == ._stripeAccountId { + _confirmStripeAccountId() + } + } + + if stripeAccountIdIsInvalid == true { + Text("Identifiant Stripe invalide. Vous ne pouvez pas activer le paiement en ligne.").foregroundStyle(.logoRed) + } + + if stripeAccountId.isEmpty == false { + Button("Vérifier le compte Stripe") { + _confirmStripeAccountId() + } + .disabled(isValidating) + } + + if let errorMessage { + Text(errorMessage).foregroundStyle(.logoRed) + } + + RowButtonView("Connecter ou créer un compte Stripe", role: .destructive) { + errorMessage = nil + stripeAccountIdIsInvalid = nil + stripeAccountId = "" + stripeOnBoardingURL = nil + do { + let createStripeAccountResponse = try await StripeValidationService.createStripeConnectAccount() + print("createStripeAccountResponse", createStripeAccountResponse) + + guard let accounId = createStripeAccountResponse.accountId else { + throw ValidationError.accountNotFound + } + + let createStripeAccountLinkResponse = try await StripeValidationService.createStripeAccountLink(accounId) + print("createStripeAccountLinkResponse", createStripeAccountLinkResponse) + stripeOnBoardingURL = createStripeAccountLinkResponse.url + stripeAccountIdIsInvalid = nil + stripeAccountId = accounId + + if let stripeOnBoardingURL { + openURL(stripeOnBoardingURL) + } else { + throw ValidationError.urlNotFound + } + + } catch { + self.errorMessage = error.localizedDescription + Logger.error(error) + } + } + } header: { + Text("Compte Stripe") + } footer: { + Text("Vous devez connecter un compte Stripe à Padel Club. En cliquant sur le bouton ci-dessus, vous serez dirigé vers Stripe pour choisir votre compte Stripe à connecter ou pour en créer un.") + } + Section { let fixedFee = RegistrationPaymentMode.stripeFixedFee // Fixed fee in euros let percentageFee = RegistrationPaymentMode.stripePercentageFee @@ -526,11 +578,10 @@ struct RegistrationSetupView: View { // Text("Aucune commission Padel Club ne sera prélevée.").foregroundStyle(.logoRed).bold() } } - } private func _confirmStripeAccountId() { - stripeAccountIdIsInvalid = false + stripeAccountIdIsInvalid = nil if stripeAccountId.isEmpty { tournament.stripeAccountId = nil } else if stripeAccountId.count >= 5, stripeAccountId.starts(with: "acct_") { @@ -544,13 +595,12 @@ struct RegistrationSetupView: View { Task { isValidating = true do { - let response = try await StripeValidationService.validateStripeAccountID(accId) + let response = try await StripeValidationService.validateStripeAccount(accountId: accId) + print("validateStripeAccount", response) stripeAccountId = accId - stripeAccountIdIsInvalid = response.valid == false - enableOnlinePayment = response.valid + stripeAccountIdIsInvalid = response.canProcessPayments == false } catch { stripeAccountIdIsInvalid = true - enableOnlinePayment = false } isValidating = false } @@ -567,6 +617,8 @@ struct RegistrationSetupView: View { tournament.isTemplate = isTemplate tournament.isCorporateTournament = isCorporateTournament tournament.unregisterDeltaInHours = unregisterDeltaInHours + + var shouldDismiss = true if enableOnlineRegistration { tournament.accountIsRequired = userAccountIsRequired tournament.licenseIsRequired = licenseIsRequired @@ -589,6 +641,12 @@ struct RegistrationSetupView: View { tournament.stripeAccountId = stripeAccountId } else { tournament.stripeAccountId = nil + + if enableOnlinePayment, isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { + enableOnlinePayment = false + tournament.enableOnlinePayment = false + shouldDismiss = false + } } } else { tournament.accountIsRequired = true @@ -626,8 +684,11 @@ struct RegistrationSetupView: View { } self.dataStore.tournaments.addOrUpdate(instance: tournament) - - dismiss() + if shouldDismiss { + dismiss() + } else { + presentErrorAlert = true + } } }