diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 6e29fde..46cbbba 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -976,6 +976,9 @@ FFE8B5B72DA8763800BDE966 /* PaymentInfoSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5B62DA8763800BDE966 /* PaymentInfoSheetView.swift */; }; FFE8B5B82DA8763800BDE966 /* PaymentInfoSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5B62DA8763800BDE966 /* PaymentInfoSheetView.swift */; }; FFE8B5B92DA8763800BDE966 /* PaymentInfoSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5B62DA8763800BDE966 /* PaymentInfoSheetView.swift */; }; + FFE8B5BB2DA9896800BDE966 /* RefundService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5BA2DA9896800BDE966 /* RefundService.swift */; }; + FFE8B5BC2DA9896800BDE966 /* RefundService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5BA2DA9896800BDE966 /* RefundService.swift */; }; + FFE8B5BD2DA9896800BDE966 /* RefundService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8B5BA2DA9896800BDE966 /* RefundService.swift */; }; FFE8C2C02C7601E80046B243 /* ConfirmButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8C2BF2C7601E80046B243 /* ConfirmButtonView.swift */; }; FFEF7F4E2BDE69130033D0F0 /* MenuWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFEF7F4D2BDE69130033D0F0 /* MenuWarningView.swift */; }; FFF0241E2BF48B15001F14B4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = FFF0241D2BF48B15001F14B4 /* Localizable.strings */; }; @@ -1415,6 +1418,7 @@ FFE2D2E12C231BEE00D0C7BE /* SupportButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportButtonView.swift; sourceTree = ""; }; FFE8B5B22DA848D300BDE966 /* OnlineWaitingListFaqSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineWaitingListFaqSheetView.swift; sourceTree = ""; }; FFE8B5B62DA8763800BDE966 /* PaymentInfoSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentInfoSheetView.swift; sourceTree = ""; }; + FFE8B5BA2DA9896800BDE966 /* RefundService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundService.swift; sourceTree = ""; }; FFE8C2BF2C7601E80046B243 /* ConfirmButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmButtonView.swift; sourceTree = ""; }; FFEF7F4D2BDE69130033D0F0 /* MenuWarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuWarningView.swift; sourceTree = ""; }; FFF0241C2BF48B15001F14B4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; @@ -1521,8 +1525,6 @@ FFD784002B91BF79000F62A6 /* Launch Screen.storyboard */, FF2B515F2C7E300500FFF126 /* SeedData */, C4A47D722B72881500ADC637 /* Views */, - FF3A73F22D37C34C007E3032 /* RegistrationInfoSheetView.swift */, - FF3A74312D37DCF2007E3032 /* InscriptionLegendView.swift */, FF3F74FD2B91A087004CFE0E /* ViewModel */, C4A47D5F2B6D3B2D00ADC637 /* Data */, FFF8ACD02B9238A2008466FA /* Utils */, @@ -2019,6 +2021,8 @@ FFE103112C366E5900684FC9 /* ImagePickerView.swift */, FFBFC3942CF05CBB000EBD8D /* DateMenuView.swift */, FFE8B5B62DA8763800BDE966 /* PaymentInfoSheetView.swift */, + FF3A73F22D37C34C007E3032 /* RegistrationInfoSheetView.swift */, + FF3A74312D37DCF2007E3032 /* InscriptionLegendView.swift */, ); path = Shared; sourceTree = ""; @@ -2051,6 +2055,7 @@ FF4AB6B42B9248200002987F /* NetworkManager.swift */, FFC1E1092BAC2A77008D6F59 /* NetworkFederalService.swift */, FF6EC9052B947A1000EA7F5A /* NetworkManagerError.swift */, + FFE8B5BA2DA9896800BDE966 /* RefundService.swift */, ); path = Network; sourceTree = ""; @@ -2671,6 +2676,7 @@ C488C8352CC7E4240082001F /* BaseMatchScheduler.swift in Sources */, C488C8372CC7E4240082001F /* BasePlayerRegistration.swift in Sources */, C488C8382CC7E4240082001F /* BaseTeamScore.swift in Sources */, + FFE8B5BB2DA9896800BDE966 /* RefundService.swift in Sources */, C488C8392CC7E4240082001F /* BaseTournament.swift in Sources */, FF6087EC2BE26A2F004E1E47 /* BroadcastView.swift in Sources */, FFF964552BC266CF00EEF017 /* SchedulerView.swift in Sources */, @@ -3130,6 +3136,7 @@ FF4CBFF22C996C0600151637 /* TournamentFormatSelectionView.swift in Sources */, FF17CA592CC02FEB003C7323 /* CoachListView.swift in Sources */, FF4CBFF32C996C0600151637 /* MatchListView.swift in Sources */, + FFE8B5BD2DA9896800BDE966 /* RefundService.swift in Sources */, FF4CBFF42C996C0600151637 /* PadelClubApp.swift in Sources */, FF4CBFF52C996C0600151637 /* TournamentSettingsView.swift in Sources */, C4A36F582CE2626A003738C6 /* TournamentLibrary.swift in Sources */, @@ -3426,6 +3433,7 @@ FF70FB712C90584900129CC2 /* TournamentFormatSelectionView.swift in Sources */, FF17CA582CC02FEB003C7323 /* CoachListView.swift in Sources */, FF70FB722C90584900129CC2 /* MatchListView.swift in Sources */, + FFE8B5BC2DA9896800BDE966 /* RefundService.swift in Sources */, FF70FB732C90584900129CC2 /* PadelClubApp.swift in Sources */, FF70FB742C90584900129CC2 /* TournamentSettingsView.swift in Sources */, C4A36F592CE2626A003738C6 /* TournamentLibrary.swift in Sources */, diff --git a/PadelClub/Data/PlayerRegistration.swift b/PadelClub/Data/PlayerRegistration.swift index af1a1e9..5493306 100644 --- a/PadelClub/Data/PlayerRegistration.swift +++ b/PadelClub/Data/PlayerRegistration.swift @@ -378,6 +378,10 @@ final class PlayerRegistration: BasePlayerRegistration, SideStorable { return false } + func hasPaidOnline() -> Bool { + registrationStatus == .confirmed && paymentId != nil && paymentType == .creditCard + } + enum PlayerDataSource: Int, Codable { case frenchFederation = 0 case beachPadel = 1 diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index 62954c7..7331839 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -79,6 +79,10 @@ final class TeamRegistration: BaseTeamRegistration, SideStorable { players().anySatisfy({ $0.registeredOnline }) } + func hasPaidOnline() -> Bool { + players().anySatisfy({ $0.hasPaidOnline() }) + } + func unrankedOrUnknown() -> Bool { players().anySatisfy({ $0.source == nil }) } diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index dad95cf..8bfe7c4 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -2379,6 +2379,14 @@ defer { unsortedTeams().filter({ $0.hasRegisteredOnline() }) } + func paidOnlineTeams() -> [TeamRegistration] { + unsortedTeams().filter({ $0.hasPaidOnline() }) + } + + func refundTeams() { + + } + func shouldWarnOnlineRegistrationUpdates() -> Bool { enableOnlineRegistration && onlineTeams().isEmpty == false && hasEnded() == false && hasStarted() == false } diff --git a/PadelClub/Utils/Network/RefundService.swift b/PadelClub/Utils/Network/RefundService.swift new file mode 100644 index 0000000..721e15c --- /dev/null +++ b/PadelClub/Utils/Network/RefundService.swift @@ -0,0 +1,44 @@ +// +// RefundService.swift +// PadelClub +// +// Created by razmig on 11/04/2025. +// + +import Foundation +import LeStorage + +class RefundService { + static func processRefund(teamRegistrationId: String) async throws -> RefundResponse { + let service = try StoreCenter.main.service() + let urlRequest = try service._baseRequest(servicePath: "refund-tournament/\(teamRegistrationId)/", method: .post, requiresToken: true) + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw RefundError.requestFailed + } + + let refundResponse = try JSONDecoder().decode(RefundResponse.self, from: data) + return refundResponse + } +} + +struct RefundResponse: Codable { + let success: Bool + let message: String + let players: [PlayerRegistration]? + + enum CodingKeys: String, CodingKey { + case success + case message + case players + } +} + +enum RefundError: Error { + case requestFailed + case unauthorized + case unknown +} diff --git a/PadelClub/InscriptionLegendView.swift b/PadelClub/Views/Shared/InscriptionLegendView.swift similarity index 100% rename from PadelClub/InscriptionLegendView.swift rename to PadelClub/Views/Shared/InscriptionLegendView.swift diff --git a/PadelClub/RegistrationInfoSheetView.swift b/PadelClub/Views/Shared/RegistrationInfoSheetView.swift similarity index 100% rename from PadelClub/RegistrationInfoSheetView.swift rename to PadelClub/Views/Shared/RegistrationInfoSheetView.swift diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index d5fa22c..f26b75f 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -25,7 +25,9 @@ struct EditingTeamView: View { @State private var wildCardGroupStage : Bool @State private var name: String @FocusState private var focusedField: TeamRegistration.CodingKeys? - + @State private var isProcessingRefund = false + @State private var refundMessage: String? + var messageSentFailed: Binding { Binding { sentError != nil @@ -101,6 +103,35 @@ struct EditingTeamView: View { } .headerProminence(.increased) + if team.hasRegisteredOnline() || team.hasPaidOnline() { + Section { + LabeledContent { + Text(team.hasRegisteredOnline() ? "Oui" : "Non") + } label: { + Text("Inscrits en ligne") + } + + LabeledContent { + Text(team.hasPaidOnline() ? "Oui" : "Non") + } label: { + Text("Payé en ligne") + } + + if team.hasPaidOnline() { + if let refundMessage, refundMessage.isEmpty == false { + Text(refundMessage).foregroundStyle(.logoRed) + } + RowButtonView("Rembourser l'équipe", role: .destructive) { + await _processRefund() + } + } + } footer: { + if team.hasPaidOnline() { + Text("Le remboursement passe part le service de Stripe qui re-crédite le moyen de paiement utilisé du montant payé.") + } + } + } + Section { if let callDate = team.callDate { LabeledContent() { @@ -378,6 +409,32 @@ struct EditingTeamView: View { private var _networkErrorMessage: String { ContactManagerError.getNetworkErrorMessage(sentError: sentError, networkMonitorConnected: networkMonitor.connected) } + + private func _processRefund() async { + isProcessingRefund = true + do { + let response = try await RefundService.processRefund(teamRegistrationId: team.id) + + await MainActor.run { + isProcessingRefund = false + refundMessage = response.message + + if response.success { + if let players = response.players { + tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players) + } else { + refundMessage = response.message + "\nLa mise à jour des équipes n'a pas été reçue pour le moment." + } + + } + } + } catch { + await MainActor.run { + isProcessingRefund = false + refundMessage = "Erreur lors du remboursement. Veuillez réessayer." + } + } + } } //#Preview { diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentStatusView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentStatusView.swift index 2d3ce23..c32c117 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentStatusView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentStatusView.swift @@ -42,65 +42,123 @@ struct TournamentStatusView: View { } } + let paidOnlineTeams = tournament.paidOnlineTeams() + Section { RowButtonView("Supprimer le tournoi", role: .destructive) { if tournament.payment == nil { - do { - let event = tournament.eventObject() - let isLastTournament = event?.tournaments.count == 1 - - if tournament.onlineTeams().isEmpty == false { - tournament.isDeleted = true - try dataStore.tournaments.addOrUpdate(instance: tournament) - } else { - if let event, isLastTournament { - try dataStore.events.delete(instance: event) - } else { - try dataStore.tournaments.delete(instance: tournament) - } - } - if eventDismiss == false || isLastTournament { - navigation.path = NavigationPath() + let event = tournament.eventObject() + let isLastTournament = event?.tournaments.count == 1 + + if tournament.onlineTeams().isEmpty == false { + tournament.isDeleted = true + dataStore.tournaments.addOrUpdate(instance: tournament) + } else { + if let event, isLastTournament { + dataStore.events.delete(instance: event) } else { - dismiss() + dataStore.tournaments.delete(instance: tournament) } - } catch { - Logger.error(error) + } + if eventDismiss == false || isLastTournament { + navigation.path = NavigationPath() + } else { + dismiss() } } else { tournament.endDate = Date() tournament.isDeleted = true tournament.navigationPath.removeAll() - do { - try dataStore.tournaments.addOrUpdate(instance: tournament) - if eventDismiss == false { - navigation.path = NavigationPath() - } else { - dismiss() - } - } catch { - Logger.error(error) + _save() + if eventDismiss == false { + navigation.path = NavigationPath() + } else { + dismiss() } } } } footer: { - if tournament.payment == nil { + if paidOnlineTeams.isEmpty == false { + Text("Ce tournoi ne peut pas étre supprimé, seulement annulé car il contient des équipes ayant payé en ligne.") + } else if tournament.payment == nil { Text("Ce tournoi sera supprimé et n'a pas été comptabilisé dans vos achats. Toutes les données seront supprimées.") } else { Text("Ce tournoi sera supprimé et a déjà été comptabilisé dans vos achats. Toutes les données seront supprimées.") } } + .disabled(paidOnlineTeams.isEmpty == false) if tournament.hasEnded() == false && tournament.isCanceled == false { Section { + + @Bindable var bindableTournament: Tournament = tournament + + if paidOnlineTeams.isEmpty == false { + LabeledContent { + Text(paidOnlineTeams.count.formatted()) + } label: { + Text("Équipes ayant payé en ligne") + } + + + Toggle(isOn: $bindableTournament.enableOnlinePaymentRefund) { + Text("Remboursement possible") + } + + if tournament.enableOnlinePaymentRefund { + if let refundDateLimit = tournament.refundDateLimit { + LabeledContent { + Text(refundDateLimit.formatted()) + } label: { + Text("Date limite") + } + + if refundDateLimit.isEarlierThan(Date()) == false { + Text("Le remboursement est toujours possible") + } else { + Text("La date limite a été dépassé") + FooterButtonView("Retirer la date limite ?", role: .destructive) { + tournament.refundDateLimit = nil + _save() + } + } + } + } + + if tournament.enableOnlinePaymentRefund { + if let refundDateLimit = tournament.refundDateLimit { + if refundDateLimit.isEarlierThan(Date()) == false { + Text("\(paidOnlineTeams.count) équipe\(paidOnlineTeams.count.pluralSuffix) seront remboursée\(paidOnlineTeams.count.pluralSuffix)") + } else { + Text("Les équipes ayant payé en ligne ne seront pas automatiquement remboursées car la date limite a été dépassé") + } + } else { + Text("Les équipes ayant payé en ligne seront remboursées") + } + } else { + Text("Les équipes ayant payé en ligne ne seront pas automatiquement remboursées vous n'avez pas autorisé le remboursement.") + } + + Text("Si vous annulez ce tournoi vous pouvez toujours gérer les remboursements au cas par cas dans la vue gestion des inscriptions du tournoi.") + } + + RowButtonView("Annuler le tournoi", role: .destructive) { + if paidOnlineTeams.isEmpty == false { + + if tournament.enableOnlinePaymentRefund { + if let refundDateLimit = tournament.refundDateLimit { + if refundDateLimit.isEarlierThan(Date()) == false { + tournament.refundTeams() + } + } else { + tournament.refundTeams() + } + } + } tournament.endDate = Date() tournament.isCanceled = true - do { - try dataStore.tournaments.addOrUpdate(instance: tournament) - } catch { - Logger.error(error) - } + _save() dismiss() } } footer: { @@ -118,6 +176,9 @@ struct TournamentStatusView: View { } .navigationTitle("Gestion du tournoi") .toolbarBackground(.visible, for: .navigationBar) + .onChange(of: tournament.enableOnlinePaymentRefund) { + _save() + } .onChange(of: tournament.endDate) { _save() } @@ -130,11 +191,7 @@ struct TournamentStatusView: View { } private func _save() { - do { - try dataStore.tournaments.addOrUpdate(instance: tournament) - } catch { - Logger.error(error) - } + dataStore.tournaments.addOrUpdate(instance: tournament) } }