From 5531dc011b4d81cbb46dfaa219599ec838f72c1a Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 8 Jun 2026 18:24:43 +0200 Subject: [PATCH 1/3] Update release version - Updated version to 0.1.0 --- PaystackCore.podspec | 2 +- PaystackUI.podspec | 2 +- Sources/PaystackSDK/Versioning/versions.plist | 4 +- Sources/PaystackUI/Charge/ChargeView.swift | 8 +- .../PaystackUI/Charge/ChargeViewModel.swift | 101 +++++++----- .../MobileMoney/MobileMoneyFlowFactory.swift | 15 ++ .../Models/MobileMoneyChannel.swift | 30 ++++ .../ChargeMobileMoneyRepository.swift | 4 +- ...swift => MobileMoneyChargeViewModel.swift} | 28 ++-- ...t => MobileMoneyProcessingViewModel.swift} | 23 ++- .../Views/ChannelSelectionView.swift | 44 ++--- ...View.swift => MobileMoneyChargeView.swift} | 24 +-- ....swift => MobileMoneyProcessingView.swift} | 12 +- .../Charge/Models/ChargePaymentType.swift | 3 +- .../Charge/Models/ChargeState.swift | 2 +- .../Charge/Models/SupportedChannel.swift | 52 ++++++ Sources/PaystackUI/Images/Images.swift | 5 + .../ghanaFlagLogo.imageset/Contents.json | 21 +++ .../ghanaFlagLogo.imageset/Flags.png | Bin 0 -> 421 bytes .../PaystackUI/Utils/StringExtensions.swift | 12 +- .../UI/Charge/ChargeViewModelTests.swift | 153 +++++++++++++++++- .../MobileMoneyChannelTests.swift | 77 +++++++++ .../MobileMoneyChargeViewModelTests.swift} | 85 ++++++---- ...MobileMoneyProcessingViewModelTests.swift} | 72 ++++++--- ...leMoneyRepositoryImplementationTests.swift | 8 +- .../MockChargeMobileMoneyRepository.swift | 6 +- ...r.swift => MockMobileMoneyContainer.swift} | 9 +- .../UI/Utils/StringExtensionsTests.swift | 51 ++++-- 28 files changed, 658 insertions(+), 195 deletions(-) create mode 100644 Sources/PaystackUI/Charge/MobileMoney/MobileMoneyFlowFactory.swift rename Sources/PaystackUI/Charge/MobileMoney/Viewmodels/{MPesaChrageViewModel.swift => MobileMoneyChargeViewModel.swift} (73%) rename Sources/PaystackUI/Charge/MobileMoney/Viewmodels/{MPesaProcessingViewModel.swift => MobileMoneyProcessingViewModel.swift} (69%) rename Sources/PaystackUI/Charge/MobileMoney/Views/{MPesaChargeView.swift => MobileMoneyChargeView.swift} (71%) rename Sources/PaystackUI/Charge/MobileMoney/Views/{MPesaProcessingView.swift => MobileMoneyProcessingView.swift} (91%) create mode 100644 Sources/PaystackUI/Charge/Models/SupportedChannel.swift create mode 100644 Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Flags.png create mode 100644 Tests/PaystackSDKTests/UI/Charge/MobileMoneyChannel/MobileMoneyChannelTests.swift rename Tests/PaystackSDKTests/UI/Charge/{MPesaCharge/MPesaChrageViewModelTests.swift => MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift} (70%) rename Tests/PaystackSDKTests/UI/Charge/{MPesaProcessing/MPesaProcessingViewModelTests.swift => MobileMoneyProcessing/MobileMoneyProcessingViewModelTests.swift} (52%) rename Tests/PaystackSDKTests/UI/Charge/Mocks/{MockMPesaContainer.swift => MockMobileMoneyContainer.swift} (72%) diff --git a/PaystackCore.podspec b/PaystackCore.podspec index 696a422..d02a10e 100644 --- a/PaystackCore.podspec +++ b/PaystackCore.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'PaystackCore' - s.version = '0.0.4' + s.version = '0.1.0' s.summary = 'The Paystack Public iOS SDK' # TODO: Add correct descriptions diff --git a/PaystackUI.podspec b/PaystackUI.podspec index b397dc2..fb99880 100644 --- a/PaystackUI.podspec +++ b/PaystackUI.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'PaystackUI' - s.version = '0.0.4' + s.version = '0.1.0' s.summary = 'The UI Flows build upon the Paystack Public iOS SDK' # TODO: Add correct descriptions diff --git a/Sources/PaystackSDK/Versioning/versions.plist b/Sources/PaystackSDK/Versioning/versions.plist index b8a87ff..5f5de06 100644 --- a/Sources/PaystackSDK/Versioning/versions.plist +++ b/Sources/PaystackSDK/Versioning/versions.plist @@ -3,8 +3,8 @@ Description - Alpine_Swift + 0.1.0 Version - 0.0.4 + 0.1.0 diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index 85509e5..749ccfa 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -48,7 +48,7 @@ struct ChargeView: View { .aspectRatio(contentMode: .fit) .frame(width: 140) } - .task(viewModel.verifyAccessCodeAndProceedWithCard) + .task(viewModel.verifyAccessCodeAndProceed) .modalCancelButton(showConfirmation: viewModel.displayCloseButtonConfirmation, onCancelled: chargeCancelled) } @@ -59,8 +59,10 @@ struct ChargeView: View { case .card(let transactionInformation): ChargeCardView(transactionDetails: transactionInformation, chargeContainer: viewModel) - case .mobileMoney(transactionInformation: let transactionInformation): - MPesaChargeView(chargeCardContainer: viewModel, transactionDetails: transactionInformation) + case .mobileMoney(let transactionInformation, let provider): + MobileMoneyFlowFactory.view(for: provider, + chargeContainer: viewModel, + transactionDetails: transactionInformation) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 98e2277..a902717 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -19,37 +19,22 @@ class ChargeViewModel: ObservableObject { } @MainActor - func verifyAccessCodeAndProceedWithCard() async { - var supportedChannels: [SupportedChannels] = [] + func verifyAccessCodeAndProceed() async { do { transactionState = .loading() - let accessCodeResponse = try await repository.verifyAccessCode(accessCode) - guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card || $0 == .mobileMoney }) else { - let message = "Card/MPesa payments are not supported. " + + let response = try await repository.verifyAccessCode(accessCode) + let supported = resolveSupportedChannels(from: response) + + guard !supported.isEmpty else { + let message = "No supported payment methods. " + "Please reach out to your merchant for further information" - let cause = "There are currently no payment channels on " + - "your integration that are supported by the SDK" + let cause = "No payment channels on this integration " + + "are supported by the SDK" throw ChargeError(displayMessage: message, causeMessage: cause) } - let mobileMoneyChannel = accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false - - accessCodeResponse.paymentChannels.forEach { - if $0 == .card { - supportedChannels.append(.CARD) - } - if $0 == .mobileMoney && accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false { - supportedChannels.append(.MPESA) - } - } - - if mobileMoneyChannel { - transactionState = .channelSelection( - transactionInformation: accessCodeResponse, supportedChannels: supportedChannels) - } else { - self.transactionDetails = accessCodeResponse - transactionState = .payment(type: .card(transactionInformation: accessCodeResponse)) - } + transactionDetails = response + transactionState = nextState(for: supported, response: response) } catch { let error = ChargeError(error: error) Logger.error("Verify access code failed with error: %@", @@ -59,24 +44,62 @@ class ChargeViewModel: ObservableObject { } } -} + private func resolveSupportedChannels(from response: VerifyAccessCode) -> [SupportedChannel] { + var result: [SupportedChannel] = [] + + if response.paymentChannels.contains(.card) { + result.append(.card) + } + + if response.paymentChannels.contains(.mobileMoney), + let providers = response.channelOptions?.mobileMoney, !providers.isEmpty { + let allowed = filtered(providers) + result.append(contentsOf: allowed.map { .mobileMoney($0) }) + } + + return result + } -enum SupportedChannels: String, CaseIterable { - case CARD = "CARD" - case MPESA = "MPESA" - case unsupportedChannel - - var image: Image { - switch self { - case .CARD: - return Image("cardLogo", bundle: .current) - case .MPESA: - return Image("kenyaSHLogo", bundle: .current) - case .unsupportedChannel: - return Image(systemName: "exclamationmark.triangle.fill") + private func filtered(_ providers: [MobileMoneyChannel]) -> [MobileMoneyChannel] { + guard let allowlist = Self.supportedMobileMoneyProviders else { + return providers } + return providers.filter { allowlist.contains($0.key.uppercased()) } } + private func nextState(for channels: [SupportedChannel], + response: VerifyAccessCode) -> ChargeState { + if channels.count == 1, case .card = channels[0] { + return .payment(type: .card(transactionInformation: response)) + } + + if !channels.contains(.card), + channels.count == 1, + case .mobileMoney(let provider) = channels[0] { + return .payment(type: .mobileMoney(transactionInformation: response, + provider: provider)) + } + + return .channelSelection(transactionInformation: response, + supportedChannels: channels) + } + +} + +// MARK: - Mobile money provider allowlist +extension ChargeViewModel { + + /// Mobile money provider keys (`MobileMoneyChannel.key`, uppercased) that + /// the SDK is allowed to route to. The API may return providers we don't + /// yet have logos, copy, or phone formatters for — listing them here is + /// what opts them into the UI. + /// + /// Set to `nil` to accept every provider the API returns (no filtering). + /// Add a new key here when you've added its logo to `SupportedChannel.image` + /// and its country code / phone formatter to the relevant helpers. + static var supportedMobileMoneyProviders: Set? = [ + "MPESA", "ATL_KE", "MTN", "ATL", "VOD" + ] } // MARK: - Charge Container diff --git a/Sources/PaystackUI/Charge/MobileMoney/MobileMoneyFlowFactory.swift b/Sources/PaystackUI/Charge/MobileMoney/MobileMoneyFlowFactory.swift new file mode 100644 index 0000000..3171aea --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/MobileMoneyFlowFactory.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@available(iOS 14.0, *) +enum MobileMoneyFlowFactory { + + @ViewBuilder + static func view(for provider: MobileMoneyChannel, + chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode) -> some View { + + MobileMoneyChargeView(chargeCardContainer: chargeContainer, + transactionDetails: transactionDetails, + provider: provider) + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift index 78e4cde..3a577ad 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import PaystackCore struct MobileMoneyChannel: Equatable { @@ -20,3 +21,32 @@ extension MobileMoneyChannel { .init(key: "MPESA", value: "M-PESA", isNew: true, phoneNumberRegex: "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$") } } + +// MARK: - Provider-aware UI helpers + +extension MobileMoneyChannel { + + var expectedCountryCode: String { + switch key.uppercased() { + case "MPESA", "ATL_KE": + return "254" + case "MTN", "ATL", "VOD": // Ghana + return "233" + case "WAVE_CI", "ORANGE_CI", "MTN_CI": // Côte d'Ivoire + return "225" + default: + return "" + } + } + + var phoneInputAccessory: AnyView? { + switch key.uppercased() { + case "MPESA", "ATL_KE": + return AnyView(Image.kenyaFlagLogo) + case "MTN", "ATL", "VOD": + return AnyView(Image.ghanaFlagLogo) + default: + return nil + } + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift b/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift index 5603ccf..31ff400 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift @@ -3,7 +3,7 @@ import PaystackCore protocol ChargeMobileMoneyRepository { func chargeMobileMoney(phone: String, transactionId: String, provider: String) async throws -> MobileMoneyTransaction - func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction + func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction } @@ -21,7 +21,7 @@ struct ChargeMobileMoneyRepositoryImplementation: ChargeMobileMoneyRepository { return MobileMoneyTransaction.from(response) } - func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction { + func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction { let response = try await paystack.listenForMobileMoneyResponse(for: transactionId).async() return ChargeCardTransaction.from(response) } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift similarity index 73% rename from Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift rename to Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift index 3299a15..71d8375 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift @@ -1,18 +1,20 @@ import Foundation import PaystackCore -protocol MPesaContainer { +protocol MobileMoneyContainer { var transactionDetails: VerifyAccessCode { get } + var provider: MobileMoneyChannel { get } func processTransactionResponse(_ response: ChargeCardTransaction) async func displayTransactionError(_ error: ChargeError) - func restartMPesaPayment() + func restartMobileMoneyPayment() } -class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { +class MobileMoneyChargeViewModel: ObservableObject, @MainActor MobileMoneyContainer { var chargeCardContainer: ChargeContainer var repository: ChargeMobileMoneyRepository var transactionDetails: VerifyAccessCode + let provider: MobileMoneyChannel @Published var phoneNumber: String = "" @@ -21,30 +23,38 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { init(chargeCardContainer: ChargeContainer, transactionDetails: VerifyAccessCode, + provider: MobileMoneyChannel, repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { self.chargeCardContainer = chargeCardContainer self.repository = repository self.transactionDetails = transactionDetails + self.provider = provider } var isValid: Bool { - phoneNumber.count >= 10 + if !provider.phoneNumberRegex.isEmpty, + let regex = try? NSRegularExpression(pattern: provider.phoneNumberRegex) { + let formatted = phoneNumber.formatted(for: provider) + let range = NSRange(location: 0, length: formatted.utf16.count) + return regex.firstMatch(in: formatted, range: range) != nil + } + return phoneNumber.count >= 10 } @MainActor func submitPhoneNumber() async { do { let authenticationResult = try await repository.chargeMobileMoney( - phone: phoneNumber.formattedKenyanPhoneNumber, + phone: phoneNumber.formatted(for: provider), transactionId: "\(transactionDetails.transactionId ?? 0)", - provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") + provider: provider.key) transactionState = .processTransaction(transaction: authenticationResult) } catch { displayTransactionError(ChargeError(error: error)) } } - func restartMPesaPayment() { + func restartMobileMoneyPayment() { transactionState = .countdown } @@ -62,7 +72,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { case .pending: break default: - Logger.error("Unexpected M-Pesa transaction status: %@", + Logger.error("Unexpected mobile money transaction status: %@", arguments: response.status.rawValue) transactionState = .fatalError( error: .generic(withCause: "Unexpected transaction status: \(response.status.rawValue)")) @@ -76,7 +86,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { } func cancelTransaction() { - restartMPesaPayment() + restartMobileMoneyPayment() } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyProcessingViewModel.swift similarity index 69% rename from Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift rename to Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyProcessingViewModel.swift index f8613b8..22da104 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyProcessingViewModel.swift @@ -1,15 +1,15 @@ import Foundation import PaystackCore -class MPesaProcessingViewModel: ObservableObject { +class MobileMoneyProcessingViewModel: ObservableObject { - var container: MPesaContainer + var container: MobileMoneyContainer var repository: ChargeMobileMoneyRepository let mobileMoneyTransaction: MobileMoneyTransaction @Published var counter = 0 - init(container: MPesaContainer, + init(container: MobileMoneyContainer, mobileMoneyTransaction: MobileMoneyTransaction, repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { self.container = container @@ -21,6 +21,13 @@ class MPesaProcessingViewModel: ObservableObject { container.transactionDetails } + var authorizationPromptText: String { + if !mobileMoneyTransaction.message.isEmpty { + return mobileMoneyTransaction.message + } + return "Please authorize the payment with \(container.provider.value) on your phone" + } + func checkTransactionStatus() { Task { await checkPendingCharge() @@ -28,13 +35,13 @@ class MPesaProcessingViewModel: ObservableObject { } @MainActor - func initializeMPesaAuthorization() async { + func initializeMobileMoneyAuthorization() async { do { - let authenticationResult = try await repository.listenForMPesa( + let authenticationResult = try await repository.listenForMobileMoneyResponse( for: Int(mobileMoneyTransaction.transaction) ?? 0) await container.processTransactionResponse(authenticationResult) } catch { - Logger.error("Listening for M-Pesa transaction failed with error: %@", + Logger.error("Listening for mobile money transaction failed with error: %@", arguments: error.localizedDescription) container.displayTransactionError(ChargeError(error: error)) } @@ -47,14 +54,14 @@ class MPesaProcessingViewModel: ObservableObject { with: transactionDetails.accessCode) await container.processTransactionResponse(authenticationResult) } catch { - Logger.error("Checking pending M-Pesa charge failed with error: %@", + Logger.error("Checking pending mobile money charge failed with error: %@", arguments: error.localizedDescription) container.displayTransactionError(ChargeError(error: error)) } } func cancelTransaction() { - container.restartMPesaPayment() + container.restartMobileMoneyPayment() } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index c5731bb..76f48d2 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -14,15 +14,14 @@ struct ChannelSelectionView: View { var visibilityContainer: ViewVisibilityContainer @StateObject var viewModel: ChannelSelectionViewModel + let supportedChannels: [SupportedChannel] let columns = [GridItem(.flexible()), GridItem(.flexible())] - var items: [PaymentChannel] = [] + init(state: Binding, - supportedChannels: [SupportedChannels], + supportedChannels: [SupportedChannel], information: VerifyAccessCode) { self._viewModel = StateObject(wrappedValue: ChannelSelectionViewModel(state: state, information: information)) - items = supportedChannels.map { - PaymentChannel(channel: $0) - } + self.supportedChannels = supportedChannels } var body: some View { @@ -35,13 +34,13 @@ struct ChannelSelectionView: View { .multilineTextAlignment(.center) GeometryReader { geo in LazyVGrid(columns: columns) { - ForEach(items) { value in - ChannelView(channelTitle: value.title, image: value.image) + ForEach(supportedChannels) { channel in + ChannelView(channelTitle: channel.displayTitle, image: channel.image) .padding(.singlePadding) .onTapGesture { - viewModel.chooseChannel(channel: value.channel) + viewModel.choose(channel) } - .frame(width: (geo.size.width / CGFloat(items.count)).rounded()) + .frame(width: (geo.size.width / CGFloat(supportedChannels.count)).rounded()) } } } @@ -59,31 +58,14 @@ class ChannelSelectionViewModel: ObservableObject { self.information = information } - func chooseChannel(channel: SupportedChannels) { - let message = "Card/MPesa payments are not supported. " + - "Please reach out to your merchant for further information" - let cause = "There are currently no payment channels on " + - "your integration that are supported by the SDK" + func choose(_ channel: SupportedChannel) { switch channel { - case .CARD: + case .card: state = .payment(type: .card(transactionInformation: self.information)) - case .MPESA: - state = .payment(type: .mobileMoney(transactionInformation: self.information)) - case .unsupportedChannel: - state = .error(ChargeError(displayMessage: message, causeMessage: cause)) + case .mobileMoney(let provider): + state = .payment(type: .mobileMoney(transactionInformation: self.information, + provider: provider)) } - - } -} - -struct PaymentChannel: Identifiable { - var id: String = UUID().uuidString - let channel: SupportedChannels - var title: String { - channel.rawValue - } - var image: Image { - channel.image } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift similarity index 71% rename from Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift rename to Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift index 04239cc..5b51d93 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift @@ -1,18 +1,21 @@ import SwiftUI @available(iOS 14.0, *) -struct MPesaChargeView: View { +struct MobileMoneyChargeView: View { @StateObject - var viewModel: MPesaChrageViewModel + var viewModel: MobileMoneyChargeViewModel private let phoneNumberMaximumLength = 15 @State private var showPhoneNumberError = false init( chargeCardContainer: ChargeContainer, - transactionDetails: VerifyAccessCode) { - self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel( - chargeCardContainer: chargeCardContainer, transactionDetails: transactionDetails)) + transactionDetails: VerifyAccessCode, + provider: MobileMoneyChannel) { + self._viewModel = StateObject(wrappedValue: MobileMoneyChargeViewModel( + chargeCardContainer: chargeCardContainer, + transactionDetails: transactionDetails, + provider: provider)) } var body: some View { @@ -22,19 +25,19 @@ struct MPesaChargeView: View { case .error(let chargeError): ErrorView(message: chargeError.message, buttonText: "Try again", - buttonAction: viewModel.restartMPesaPayment) + buttonAction: viewModel.restartMobileMoneyPayment) case .fatalError(let error): ErrorView(message: error.message, automaticallyDismissWith: .init( error: error, transactionReference: viewModel.transactionDetails.reference)) case .processTransaction(let transaction): - MPesaProcessingView(container: viewModel, - mobileMoneyTransaction: transaction) + MobileMoneyProcessingView(container: viewModel, + mobileMoneyTransaction: transaction) case .countdown: VStack(spacing: .triplePadding) { - Text("Please enter your mobile money number to begin this payment") + Text("Please enter the mobile money number to begin this payment") .font(.body16M) .foregroundColor(.stackBlue) .multilineTextAlignment(.center) @@ -52,6 +55,7 @@ struct MPesaChargeView: View { @ViewBuilder var phoneNumber: some FormInputItemView { + TextFieldFormInputView(title: "Phone Number", placeholder: "070 000 0000", text: $viewModel.phoneNumber, @@ -59,7 +63,7 @@ struct MPesaChargeView: View { maxLength: phoneNumberMaximumLength, inErrorState: $showPhoneNumberError, defaultFocused: true, - accessoryView: Image.kenyaFlagLogo) + accessoryView: viewModel.provider.phoneInputAccessory) .minLength(10, errorMessage: "Invalid Phone Number") } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift similarity index 91% rename from Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift rename to Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift index 5d6e54e..efd726f 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift @@ -1,14 +1,14 @@ import SwiftUI @available(iOS 14.0, *) -struct MPesaProcessingView: View { +struct MobileMoneyProcessingView: View { @StateObject - var viewModel: MPesaProcessingViewModel + var viewModel: MobileMoneyProcessingViewModel - init(container: MPesaContainer, + init(container: MobileMoneyContainer, mobileMoneyTransaction: MobileMoneyTransaction) { - self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel( + self._viewModel = StateObject(wrappedValue: MobileMoneyProcessingViewModel( container: container, mobileMoneyTransaction: mobileMoneyTransaction)) } @@ -23,7 +23,7 @@ struct MPesaProcessingView: View { .foregroundColor(.stackBlue) .multilineTextAlignment(.center) - Text("Please enter your pin on your phone to complete this payment") + Text(viewModel.authorizationPromptText) .font(.body16M) .foregroundColor(.stackBlue) .multilineTextAlignment(.center) @@ -33,7 +33,7 @@ struct MPesaProcessingView: View { action: viewModel.checkTransactionStatus) } .padding(.doublePadding) - .task(viewModel.initializeMPesaAuthorization) + .task(viewModel.initializeMobileMoneyAuthorization) } } diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index 61b82a8..53de317 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -3,5 +3,6 @@ import Foundation // TODO: Add an extension to map from payment channels once those are defined enum ChargePaymentType: Equatable { case card(transactionInformation: VerifyAccessCode) - case mobileMoney(transactionInformation: VerifyAccessCode) + case mobileMoney(transactionInformation: VerifyAccessCode, + provider: MobileMoneyChannel) } diff --git a/Sources/PaystackUI/Charge/Models/ChargeState.swift b/Sources/PaystackUI/Charge/Models/ChargeState.swift index 62e4130..1c274e5 100644 --- a/Sources/PaystackUI/Charge/Models/ChargeState.swift +++ b/Sources/PaystackUI/Charge/Models/ChargeState.swift @@ -4,7 +4,7 @@ import PaystackCore enum ChargeState { case loading(message: String? = nil) case payment(type: ChargePaymentType) - case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannels]) + case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannel]) case error(ChargeError) case success(amount: AmountCurrency, merchant: String, details: ChargeCompletionDetails) diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift new file mode 100644 index 0000000..dd87e26 --- /dev/null +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -0,0 +1,52 @@ +import SwiftUI +import PaystackCore + +/// A resolved payment channel that the SDK is willing to route to for the +/// current transaction. One entry per card option, plus one entry per +/// supported mobile money provider returned by `verifyAccessCode`. +enum SupportedChannel: Equatable, Identifiable { + case card + case mobileMoney(MobileMoneyChannel) + + var id: String { + switch self { + case .card: + return "card" + case .mobileMoney(let channel): + return "mobile_money.\(channel.key)" + } + } + + var displayTitle: String { + switch self { + case .card: + return "Card" + case .mobileMoney(let channel): + return channel.value + } + } + + var image: Image { + switch self { + case .card: + return Image("cardLogo", bundle: .current) + case .mobileMoney(let channel): + return Self.image(forMobileMoneyKey: channel.key) + } + } + + /// Maps known Paystack mobile money provider keys to a bundled logo. + /// Falls back to a generic SF Symbol when the SDK has no logo for the + /// provider yet — keeps the channel-selection screen renderable when a + /// future provider lights up via the allowlist. + private static func image(forMobileMoneyKey key: String) -> Image { + switch key.uppercased() { + case "MPESA", "ATL_KE": + return Image("kenyaSHLogo", bundle: .current) + case "MTN", "ATL", "VOD": + return Image("kenyaSHLogo", bundle: .current) + default: + return Image(systemName: "creditcard") + } + } +} diff --git a/Sources/PaystackUI/Images/Images.swift b/Sources/PaystackUI/Images/Images.swift index 9e92a95..7cdf492 100644 --- a/Sources/PaystackUI/Images/Images.swift +++ b/Sources/PaystackUI/Images/Images.swift @@ -41,6 +41,11 @@ extension Image { Image("kenyaFlagLogo", bundle: .current) .frame(height: 16) } + + static var ghanaFlagLogo: some View { + Image("ghanaFlagLogo", bundle: .current) + .frame(height: 16) + } static var messageBubbleLogo: some View { Image("messageBubbleLogo", bundle: .current) diff --git a/Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Contents.json new file mode 100644 index 0000000..42fbc55 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flags.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Flags.png b/Sources/PaystackUI/Images/Images.xcassets/ghanaFlagLogo.imageset/Flags.png new file mode 100644 index 0000000000000000000000000000000000000000..c5d2bc2d462b78711760b3c3d8957032d92085e2 GIT binary patch literal 421 zcmeAS@N?(olHy`uVBq!ia0vp^5GZx^prwfgF}}M_)$E)e-c@Ne7+Lbh?3y^w370~qErUA%=FyEc^juC19d5Q zx;TbZFrJ;VH;c(pr1kt=ot!XU!AVLkJc_PC?KTdx_PES?!g`5ilAR{AU=EACqs~^* zgNNJ%vU5X3LINT`u-6w{pUD{LI3xBhtG&cCqdS)A<>!l5Rti_eM~JkhvOZL6xyAXg z{nf(&QL(&-ylsybTBxr$kSrtJ^MJ>lEmCRY9EY^Vr3sVQ-(YP^cKWkB`#V=ir1R?K zN^`Hf&ehIL>Q^~e_{{j@7lY*Nsu_+B<})|+c_rVSb>c^%{Jh6sUFUU}F#lWfXL3xu z-nMi9?0+TM-$+okkXRpWo*mB9mCDQbQO$PAKdXwmIU8bX%GNdAn^b;jf5sIy6SI|9 zF26PYFS^9T`PmzxP0Hsi_XTe3Z)EIGa9+AhV}XC%fz%nx5}WF#pXc=1;;Y`U0~m@7 Mp00i_>zopr0BPl+Gynhq literal 0 HcmV?d00001 diff --git a/Sources/PaystackUI/Utils/StringExtensions.swift b/Sources/PaystackUI/Utils/StringExtensions.swift index 5fb01f9..6a7d294 100644 --- a/Sources/PaystackUI/Utils/StringExtensions.swift +++ b/Sources/PaystackUI/Utils/StringExtensions.swift @@ -12,16 +12,20 @@ extension String { from: self) } - var formattedKenyanPhoneNumber: String { + func formatted(for provider: MobileMoneyChannel) -> String { let trimmed = self.removingAllWhitespaces - if trimmed.hasPrefix("+254") { + let countryCode = provider.expectedCountryCode + + guard !countryCode.isEmpty else { return trimmed } + + if trimmed.hasPrefix("+\(countryCode)") { return trimmed } - if trimmed.hasPrefix("254") { + if trimmed.hasPrefix(countryCode) { return "+" + trimmed } if trimmed.hasPrefix("0") { - return "+254" + trimmed.dropFirst() + return "+\(countryCode)" + trimmed.dropFirst() } return trimmed } diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 779c1b7..ebb6b24 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -1,4 +1,5 @@ import XCTest +import PaystackCore @testable import PaystackUI final class ChargeViewModelTests: PSTestCase { @@ -6,12 +7,23 @@ final class ChargeViewModelTests: PSTestCase { var serviceUnderTest: ChargeViewModel! var mockRepo: MockChargeRepository! + /// Snapshot the production allowlist on entry so individual tests can + /// mutate `supportedMobileMoneyProviders` freely and we restore the + /// original value in `tearDown`. Avoids order-dependent test leakage. + private static let productionAllowlist = ChargeViewModel.supportedMobileMoneyProviders + override func setUpWithError() throws { try super.setUpWithError() + ChargeViewModel.supportedMobileMoneyProviders = Self.productionAllowlist mockRepo = MockChargeRepository() serviceUnderTest = ChargeViewModel(accessCode: "access_code_test", repository: mockRepo) } + override func tearDownWithError() throws { + ChargeViewModel.supportedMobileMoneyProviders = Self.productionAllowlist + try super.tearDownWithError() + } + func testVerifyAccessCodeSetsViewStateAsCardDetailsWhenSuccessful() async { let cardOnlyAccessCode = VerifyAccessCode(amount: 10000, currency: "USD", @@ -23,19 +35,19 @@ final class ChargeViewModelTests: PSTestCase { reference: "test_reference", channelOptions: nil) mockRepo.expectedVerifyAccessCode = cardOnlyAccessCode - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .payment(type: .card(transactionInformation: cardOnlyAccessCode))) } func testVerifyAccessCodeSetsViewStateAsErrorWhenUnsuccessful() async { mockRepo.expectedErrorResponse = ChargeError.generic - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .error(.generic)) } func testVerifyAccessCodeSetsViewStateAsErrorWhenCardIsNotASupportedPaymentChannel() async { - let expectedMessage = "Card payments are not supported. " + + let expectedMessage = "No supported payment methods. " + "Please reach out to your merchant for further information" mockRepo.expectedVerifyAccessCode = .init(amount: 10000, currency: "USD", @@ -45,11 +57,108 @@ final class ChargeViewModelTests: PSTestCase { merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", reference: "test_reference", channelOptions: .example) - await serviceUnderTest.verifyAccessCodeAndProceedWithCard() + await serviceUnderTest.verifyAccessCodeAndProceed() XCTAssertEqual(serviceUnderTest.transactionState, .error(.init(message: expectedMessage))) } + // MARK: - Resolver and auto-route + + func testAutoRoutesToMobileMoneyWhenCardUnsupportedAndExactlyOneAllowlistedProvider() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .mobileMoney(transactionInformation: response, + provider: .mpesaFixture))) + } + + func testShowsChannelSelectionWhenMultipleMobileMoneyProvidersAndNoCard() async { + ChargeViewModel.supportedMobileMoneyProviders = nil // accept everything + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.mobileMoney(.mtnFixture), + .mobileMoney(.vodafoneFixture)])) + } + + func testShowsChannelSelectionWhenCardAndMobileMoneyBothSupported() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.card, .mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .mobileMoney(.mpesaFixture)])) + } + + func testAllowlistFiltersOutUnknownProvidersAndResultsInError() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedMessage = "No supported payment methods. " + + "Please reach out to your merchant for further information" + XCTAssertEqual(serviceUnderTest.transactionState, + .error(.init(message: expectedMessage))) + } + + func testAllowlistGatesAutoRouteWhenMerchantHasMoreProvidersThanSdkSupports() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture, .mtnFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .mobileMoney(transactionInformation: response, + provider: .mpesaFixture))) + } + + func testNilAllowlistAcceptsEveryMobileMoneyProvider() async { + ChargeViewModel.supportedMobileMoneyProviders = nil + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mtnFixture, .vodafoneFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.mobileMoney(.mtnFixture), + .mobileMoney(.vodafoneFixture)])) + } + + func testTransactionDetailsIsSetAfterResolvingMobileMoneyAutoRoute() async { + // Regression guard: pre-PR-2 the MM branch never set `transactionDetails`, + // so downstream UI checks (inTestMode, chargeCancelled) saw nil. + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with(channels: [.mobileMoney], + mobileMoney: [.mpesaFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionDetails, response) + } + func testViewShouldBeCenteredForSpecifiedStates() { serviceUnderTest.transactionState = .loading() XCTAssertFalse(serviceUnderTest.centerView) @@ -107,6 +216,8 @@ extension ChargeState: Equatable { return firstAmount == secondAmount && firstMerchant == secondMerchant && firstDetails == secondDetails case (.payment(let first), .payment(let second)): return first == second + case (.channelSelection(let firstInfo, let firstChannels), .channelSelection(let secondInfo, let secondChannels)): + return firstInfo == secondInfo && firstChannels == secondChannels case (.error(let first), .error(let second)): return first.localizedDescription == second.localizedDescription default: @@ -121,3 +232,37 @@ extension ChargeCompletionDetails: Equatable { lhs.reference == rhs.reference } } + +// MARK: - Test fixtures + +private extension MobileMoneyChannel { + static let mpesaFixture = MobileMoneyChannel(key: "MPESA", + value: "M-PESA", + isNew: true, + phoneNumberRegex: "") + static let mtnFixture = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") + static let vodafoneFixture = MobileMoneyChannel(key: "VOD", + value: "Vodafone", + isNew: false, + phoneNumberRegex: "") +} + +private extension VerifyAccessCode { + /// Compact helper for building `VerifyAccessCode` fixtures for resolver tests. + /// Most fields are irrelevant to channel resolution and get sensible defaults. + static func with(channels: [PaystackCore.Channel], + mobileMoney: [MobileMoneyChannel]? = nil) -> Self { + VerifyAccessCode(amount: 10000, + currency: "USD", + accessCode: "test_access", + paymentChannels: channels, + domain: .test, + merchantName: "Test Merchant", + publicEncryptionKey: "test_encryption_key", + reference: "test_reference", + channelOptions: mobileMoney.map { PaystackUI.ChannelOptions(mobileMoney: $0) }) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyChannel/MobileMoneyChannelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyChannel/MobileMoneyChannelTests.swift new file mode 100644 index 0000000..34f8a07 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyChannel/MobileMoneyChannelTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import PaystackUI + +final class MobileMoneyChannelTests: XCTestCase { + + // MARK: - expectedCountryCode + + func testExpectedCountryCodeReturnsKenyaCodeForMPesa() { + XCTAssertEqual(MobileMoneyChannel(key: "MPESA", + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "254") + } + + func testExpectedCountryCodeReturnsGhanaCodeForGhanaianProviders() { + for key in ["MTN", "ATL", "VOD"] { + XCTAssertEqual(MobileMoneyChannel(key: key, + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "233", + "Expected \(key) to map to Ghana (233)") + } + } + + func testExpectedCountryCodeReturnsIvoryCoastCodeForOrangeAndMoov() { + for key in ["WAVE_CI", "ORANGE_CI", "MTN_CI"] { + XCTAssertEqual(MobileMoneyChannel(key: key, + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "225", + "Expected \(key) to map to Côte d'Ivoire (225)") + } + } + + + func testExpectedCountryCodeReturnsEmptyForUnknownProvider() { + XCTAssertEqual(MobileMoneyChannel(key: "SOMETHING_NEW", + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "") + } + + func testExpectedCountryCodeIsCaseInsensitive() { + XCTAssertEqual(MobileMoneyChannel(key: "mpesa", + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "254") + XCTAssertEqual(MobileMoneyChannel(key: "Mtn", + value: "", + isNew: false, + phoneNumberRegex: "").expectedCountryCode, + "233") + } + + // MARK: - phoneInputAccessory + + func testPhoneInputAccessoryReturnsViewForMPesa() { + let mpesa = MobileMoneyChannel(key: "MPESA", + value: "M-PESA", + isNew: false, + phoneNumberRegex: "") + XCTAssertNotNil(mpesa.phoneInputAccessory) + } + + func testPhoneInputAccessoryReturnsNilForProviderWithoutShippedAsset() { + let unknown = MobileMoneyChannel(key: "SOMETHING_NEW", + value: "", + isNew: false, + phoneNumberRegex: "") + XCTAssertNil(unknown.phoneInputAccessory) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift similarity index 70% rename from Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift rename to Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift index da143d5..7616153 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift @@ -2,9 +2,9 @@ import XCTest import PaystackCore @testable import PaystackUI -final class MPesaChrageViewModelTests: XCTestCase { +final class MobileMoneyChargeViewModelTests: XCTestCase { - var serviceUnderTest: MPesaChrageViewModel! + var serviceUnderTest: MobileMoneyChargeViewModel! var mockChargeContainer: MockChargeContainer! var mockRepository: MockChargeMobileMoneyRepository! @@ -12,23 +12,54 @@ final class MPesaChrageViewModelTests: XCTestCase { try super.setUpWithError() mockChargeContainer = MockChargeContainer() mockRepository = MockChargeMobileMoneyRepository() - serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, - transactionDetails: .example, - repository: mockRepository) + serviceUnderTest = MobileMoneyChargeViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: .example, + provider: .example, + repository: mockRepository) } // MARK: - Phone number validation - func testIsValidReturnsFalseWhenPhoneNumberIsLessThanTenDigits() { - serviceUnderTest.phoneNumber = "012345678" - XCTAssertFalse(serviceUnderTest.isValid) + func testIsValidReturnsTrueWhenPhoneMatchesProviderRegex() { + // .example is MPESA: the regex requires +254 followed by 7xx or 11x. + // 0703... formats to +254703... which satisfies the 7([0-2]\d|...) branch. + serviceUnderTest.phoneNumber = "0703362111" + XCTAssertTrue(serviceUnderTest.isValid) } - func testIsValidReturnsTrueWhenPhoneNumberIsAtLeastTenDigits() { + func testIsValidReturnsFalseWhenFormattedPhoneFailsProviderRegex() { + // Same provider (MPESA) but the formatted input (+254123456789) doesn't + // match the regex (first digit after +254 must be 7 or 1-then-1). serviceUnderTest.phoneNumber = "0123456789" + XCTAssertFalse(serviceUnderTest.isValid) + } + + func testIsValidFallsBackToMinLengthWhenProviderHasNoRegex() { + let providerWithoutRegex = MobileMoneyChannel(key: "UNKNOWN", + value: "Unknown", + isNew: false, + phoneNumberRegex: "") + serviceUnderTest = MobileMoneyChargeViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: .example, + provider: providerWithoutRegex, + repository: mockRepository) + serviceUnderTest.phoneNumber = "0123456789" // 10 digits — passes minLength XCTAssertTrue(serviceUnderTest.isValid) } + func testIsValidFallsBackToMinLengthAndFailsForShortInput() { + let providerWithoutRegex = MobileMoneyChannel(key: "UNKNOWN", + value: "Unknown", + isNew: false, + phoneNumberRegex: "") + serviceUnderTest = MobileMoneyChargeViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: .example, + provider: providerWithoutRegex, + repository: mockRepository) + serviceUnderTest.phoneNumber = "012345678" // 9 digits — fails minLength + XCTAssertFalse(serviceUnderTest.isValid) + } + // MARK: - Initial state func testInitialStateIsCountdown() { @@ -49,9 +80,10 @@ final class MPesaChrageViewModelTests: XCTestCase { reference: "test_reference", transactionId: expectedTransactionId, channelOptions: .example) - serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, - transactionDetails: transactionDetails, - repository: mockRepository) + serviceUnderTest = MobileMoneyChargeViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: transactionDetails, + provider: .example, + repository: mockRepository) mockRepository.expectedMobileMoneyTransaction = .mPesaExample serviceUnderTest.phoneNumber = "0703362111" @@ -89,26 +121,21 @@ final class MPesaChrageViewModelTests: XCTestCase { XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.transactionId, "0") } - func testSubmitPhoneNumberWithMissingChannelOptionsDefaultsToEmptyProvider() async { - let transactionDetails = VerifyAccessCode(amount: 10000, - currency: "USD", - accessCode: "test_access", - paymentChannels: [.mobileMoney], - domain: .live, - merchantName: "Test Merchant", - publicEncryptionKey: "test_encryption_key", - reference: "test_reference", - transactionId: 1, - channelOptions: nil) - serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, - transactionDetails: transactionDetails, - repository: mockRepository) + func testSubmitPhoneNumberForwardsTheInjectedProviderKeyEvenWhenNotMPesa() async { + let mtnProvider = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") + serviceUnderTest = MobileMoneyChargeViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: .example, + provider: mtnProvider, + repository: mockRepository) mockRepository.expectedMobileMoneyTransaction = .mPesaExample serviceUnderTest.phoneNumber = "0703362111" await serviceUnderTest.submitPhoneNumber() - XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "MTN") } func testSubmitPhoneNumberOnSuccessSetsStateToProcessTransaction() async { @@ -199,9 +226,9 @@ final class MPesaChrageViewModelTests: XCTestCase { XCTAssertEqual(serviceUnderTest.transactionState, .error(error)) } - func testRestartMPesaPaymentResetsStateToCountdown() { + func testRestartMobileMoneyPaymentResetsStateToCountdown() { serviceUnderTest.transactionState = .error(.generic) - serviceUnderTest.restartMPesaPayment() + serviceUnderTest.restartMobileMoneyPayment() XCTAssertEqual(serviceUnderTest.transactionState, .countdown) } diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyProcessing/MobileMoneyProcessingViewModelTests.swift similarity index 52% rename from Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift rename to Tests/PaystackSDKTests/UI/Charge/MobileMoneyProcessing/MobileMoneyProcessingViewModelTests.swift index c28a26d..a232ed3 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyProcessing/MobileMoneyProcessingViewModelTests.swift @@ -2,62 +2,62 @@ import XCTest import PaystackCore @testable import PaystackUI -final class MPesaProcessingViewModelTests: XCTestCase { +final class MobileMoneyProcessingViewModelTests: XCTestCase { - var serviceUnderTest: MPesaProcessingViewModel! - var mockContainer: MockMPesaContainer! + var serviceUnderTest: MobileMoneyProcessingViewModel! + var mockContainer: MockMobileMoneyContainer! var mockRepository: MockChargeMobileMoneyRepository! var mobileMoneyTransaction: MobileMoneyTransaction! override func setUpWithError() throws { try super.setUpWithError() - mockContainer = MockMPesaContainer() + mockContainer = MockMobileMoneyContainer() mockRepository = MockChargeMobileMoneyRepository() mobileMoneyTransaction = .mPesaExample - serviceUnderTest = MPesaProcessingViewModel(container: mockContainer, - mobileMoneyTransaction: mobileMoneyTransaction, - repository: mockRepository) + serviceUnderTest = MobileMoneyProcessingViewModel(container: mockContainer, + mobileMoneyTransaction: mobileMoneyTransaction, + repository: mockRepository) } - // MARK: - initializeMPesaAuthorization + // MARK: - initializeMobileMoneyAuthorization - func testInitializeMPesaAuthorizationForwardsTransactionIdToRepository() async { + func testInitializeMobileMoneyAuthorizationForwardsTransactionIdToRepository() async { mockRepository.expectedChargeCardTransaction = .example - await serviceUnderTest.initializeMPesaAuthorization() - XCTAssertEqual(mockRepository.listenForMPesaTransactionId, 1504248187) + await serviceUnderTest.initializeMobileMoneyAuthorization() + XCTAssertEqual(mockRepository.listenForMobileMoneyResponseTransactionId, 1504248187) } - func testInitializeMPesaAuthorizationWithNonNumericTransactionDefaultsToZero() async { + func testInitializeMobileMoneyAuthorizationWithNonNumericTransactionDefaultsToZero() async { mobileMoneyTransaction = MobileMoneyTransaction(transaction: "not-a-number", phone: "0703362111", provider: "MPESA", channelName: "MOBILE_MONEY_x", timer: 60, message: "") - serviceUnderTest = MPesaProcessingViewModel(container: mockContainer, - mobileMoneyTransaction: mobileMoneyTransaction, - repository: mockRepository) + serviceUnderTest = MobileMoneyProcessingViewModel(container: mockContainer, + mobileMoneyTransaction: mobileMoneyTransaction, + repository: mockRepository) mockRepository.expectedChargeCardTransaction = .example - await serviceUnderTest.initializeMPesaAuthorization() + await serviceUnderTest.initializeMobileMoneyAuthorization() - XCTAssertEqual(mockRepository.listenForMPesaTransactionId, 0) + XCTAssertEqual(mockRepository.listenForMobileMoneyResponseTransactionId, 0) } - func testInitializeMPesaAuthorizationOnSuccessForwardsResponseToContainer() async { + func testInitializeMobileMoneyAuthorizationOnSuccessForwardsResponseToContainer() async { let expectedResponse = ChargeCardTransaction(status: .success) mockRepository.expectedChargeCardTransaction = expectedResponse - await serviceUnderTest.initializeMPesaAuthorization() + await serviceUnderTest.initializeMobileMoneyAuthorization() XCTAssertEqual(mockContainer.transactionResponse, expectedResponse) } - func testInitializeMPesaAuthorizationOnErrorForwardsErrorToContainer() async { + func testInitializeMobileMoneyAuthorizationOnErrorForwardsErrorToContainer() async { let expectedErrorMessage = "Subscription failed" mockRepository.expectedErrorResponse = PaystackError.response(code: 500, message: expectedErrorMessage) - await serviceUnderTest.initializeMPesaAuthorization() + await serviceUnderTest.initializeMobileMoneyAuthorization() XCTAssertEqual(mockContainer.transactionError, ChargeError(message: expectedErrorMessage)) } @@ -96,7 +96,7 @@ final class MPesaProcessingViewModelTests: XCTestCase { func testCancelTransactionAsksContainerToRestart() { serviceUnderTest.cancelTransaction() - XCTAssertTrue(mockContainer.mPesaPaymentRestarted) + XCTAssertTrue(mockContainer.mobileMoneyPaymentRestarted) } // MARK: - transactionDetails @@ -104,6 +104,34 @@ final class MPesaProcessingViewModelTests: XCTestCase { func testTransactionDetailsComesFromContainer() { XCTAssertEqual(serviceUnderTest.transactionDetails, mockContainer.transactionDetails) } + + // MARK: - authorizationPromptText + + func testAuthorizationPromptTextUsesApiMessageWhenPresent() { + // mPesaExample carries `message: "Authorize on your device"` — the + // API copy should win over the SDK fallback even when the provider + // is set. + XCTAssertEqual(serviceUnderTest.authorizationPromptText, + "Authorize on your device") + } + + func testAuthorizationPromptTextFallsBackToProviderCopyWhenApiMessageEmpty() { + let transactionWithoutMessage = MobileMoneyTransaction(transaction: "1234", + phone: "0703362111", + provider: "MPESA", + channelName: "MOBILE_MONEY_1234", + timer: 60, + message: "") + mockContainer.provider = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") + serviceUnderTest = MobileMoneyProcessingViewModel(container: mockContainer, + mobileMoneyTransaction: transactionWithoutMessage, + repository: mockRepository) + XCTAssertEqual(serviceUnderTest.authorizationPromptText, + "Please authorize the payment with MTN on your phone") + } } private extension MobileMoneyTransaction { diff --git a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift index a8bcae7..8a0ba5b 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift @@ -30,7 +30,7 @@ final class ChargeMobileMoneyRepositoryImplementationTests: PSTestCase { XCTAssertEqual(result, .jsonExample) } - func testListenForMPesaSubscribesToMobileMoneyChannelAndMapsSuccessToSuccess() async throws { + func testListenForMobileMoneyResponseSubscribesToMobileMoneyChannelAndMapsSuccessToSuccess() async throws { let transactionId = 1234 let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", eventName: "response") @@ -41,11 +41,11 @@ final class ChargeMobileMoneyRepositoryImplementationTests: PSTestCase { .expectSubscription(mockSubscription) .andReturnString(responseString) - let result = try await serviceUnderTest.listenForMPesa(for: transactionId) + let result = try await serviceUnderTest.listenForMobileMoneyResponse(for: transactionId) XCTAssertEqual(result, .init(status: .success)) } - func testListenForMPesaMapsFailedSubscriptionResponseToFailedStatus() async throws { + func testListenForMobileMoneyResponseMapsFailedSubscriptionResponseToFailedStatus() async throws { let transactionId = 4321 let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", eventName: "response") @@ -56,7 +56,7 @@ final class ChargeMobileMoneyRepositoryImplementationTests: PSTestCase { .expectSubscription(mockSubscription) .andReturnString(responseString) - let result = try await serviceUnderTest.listenForMPesa(for: transactionId) + let result = try await serviceUnderTest.listenForMobileMoneyResponse(for: transactionId) XCTAssertEqual(result, .init(status: .failed)) } diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift index f7e9054..1737add 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift @@ -9,7 +9,7 @@ class MockChargeMobileMoneyRepository: ChargeMobileMoneyRepository { var chargeMobileMoneySubmitted: (phone: String, transactionId: String, provider: String) = ("", "", "") - var listenForMPesaTransactionId: Int? + var listenForMobileMoneyResponseTransactionId: Int? var pendingChargeAccessCode: String? func chargeMobileMoney(phone: String, transactionId: String, @@ -21,8 +21,8 @@ class MockChargeMobileMoneyRepository: ChargeMobileMoneyRepository { return response } - func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction { - listenForMPesaTransactionId = transactionId + func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction { + listenForMobileMoneyResponseTransactionId = transactionId guard let response = expectedChargeCardTransaction else { throw expectedErrorResponse ?? MockError.stubNotProvided } diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMobileMoneyContainer.swift similarity index 72% rename from Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift rename to Tests/PaystackSDKTests/UI/Charge/Mocks/MockMobileMoneyContainer.swift index 1abdb58..7c0a273 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMobileMoneyContainer.swift @@ -1,11 +1,12 @@ import Foundation @testable import PaystackUI -class MockMPesaContainer: MPesaContainer { +class MockMobileMoneyContainer: MobileMoneyContainer { var transactionDetails: VerifyAccessCode = .example + var provider: MobileMoneyChannel = .example - var mPesaPaymentRestarted = false + var mobileMoneyPaymentRestarted = false var transactionResponse: ChargeCardTransaction? var transactionError: ChargeError? @@ -22,7 +23,7 @@ class MockMPesaContainer: MPesaContainer { onDisplayTransactionError?() } - func restartMPesaPayment() { - mPesaPaymentRestarted = true + func restartMobileMoneyPayment() { + mobileMoneyPaymentRestarted = true } } diff --git a/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift b/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift index 607392b..bb6ec9f 100644 --- a/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift +++ b/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift @@ -3,30 +3,59 @@ import XCTest final class StringExtensionsTests: XCTestCase { - // MARK: - formattedKenyanPhoneNumber + // MARK: - formatted(for:) - func testFormattedKenyanPhoneNumberWithLeadingZeroReplacesZeroWithCountryCode() { + func testFormattedForKenyanProviderWithLeadingZeroReplacesZeroWithCountryCode() { let phone = "0703362111" - XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + XCTAssertEqual(phone.formatted(for: .mpesa), "+254703362111") } - func testFormattedKenyanPhoneNumberWithLeading254PrependsPlus() { + func testFormattedForKenyanProviderWithLeading254PrependsPlus() { let phone = "254703362111" - XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + XCTAssertEqual(phone.formatted(for: .mpesa), "+254703362111") } - func testFormattedKenyanPhoneNumberWithLeadingPlus254ReturnsSameNumber() { + func testFormattedForKenyanProviderWithLeadingPlus254ReturnsSameNumber() { let phone = "+254703362111" - XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + XCTAssertEqual(phone.formatted(for: .mpesa), "+254703362111") } - func testFormattedKenyanPhoneNumberStripsWhitespaceBeforeFormatting() { + func testFormattedForStripsWhitespaceBeforeFormatting() { let phone = " 0703 362 111 " - XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + XCTAssertEqual(phone.formatted(for: .mpesa), "+254703362111") } - func testFormattedKenyanPhoneNumberWithUnrecognisedPrefixReturnsInputWithoutWhitespace() { + func testFormattedForWithUnrecognisedPrefixReturnsInputWithoutWhitespace() { let phone = "7033 62111" - XCTAssertEqual(phone.formattedKenyanPhoneNumber, "703362111") + XCTAssertEqual(phone.formatted(for: .mpesa), "703362111") } + + // MARK: - Cross-provider behaviour + + func testFormattedForGhanaianProviderUsesGhanaCountryCode() { + let phone = "0241234567" + XCTAssertEqual(phone.formatted(for: .mtn), "+233241234567") + } + + func testFormattedForReturnsTrimmedInputWhenProviderHasNoCountryCode() { + let unknownProvider = MobileMoneyChannel(key: "UNKNOWN", + value: "Unknown", + isNew: false, + phoneNumberRegex: "") + let phone = "0703362111" + XCTAssertEqual(phone.formatted(for: unknownProvider), "0703362111") + } +} + +// MARK: - Test fixtures + +private extension MobileMoneyChannel { + static let mpesa = MobileMoneyChannel(key: "MPESA", + value: "M-PESA", + isNew: true, + phoneNumberRegex: "") + static let mtn = MobileMoneyChannel(key: "MTN", + value: "MTN", + isNew: false, + phoneNumberRegex: "") } From d76b074cbb8b7d692b0afe4061981a112bbee3df Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 9 Jun 2026 10:41:33 +0200 Subject: [PATCH 2/3] Adding Gem Lock file and Slather gem for code coverage --- Gemfile | 1 + Gemfile.lock | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 Gemfile.lock diff --git a/Gemfile b/Gemfile index cdd3a6b..28d66e8 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source "https://rubygems.org" gem "fastlane" +gem "slather" plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6c84217 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,257 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.9) + abbrev (0.1.2) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.2) + aws-partitions (1.1109.0) + aws-sdk-core (3.224.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.101.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.188.0) + aws-sdk-core (~> 3, >= 3.224.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + claide (1.1.0) + clamp (1.5.2) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.6) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.109.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.230.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.29.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.6.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.45.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.29.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.7.6) + jwt (2.10.3) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (5.25.4) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + nokogiri (1.13.10) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + optparse (0.8.1) + os (1.1.4) + plist (3.7.2) + public_suffix (5.1.1) + racc (1.8.1) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.18.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + slather (2.7.4) + CFPropertyList (>= 2.2, < 4) + activesupport + clamp (~> 1.3) + nokogiri (>= 1.13.9) + xcodeproj (~> 1.21) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unf (0.2.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + zeitwerk (2.6.18) + +PLATFORMS + ruby + +DEPENDENCIES + fastlane + slather + +BUNDLED WITH + 1.17.2 From 1ac310f6f90bf4a683df84b3cfb003275d8275b1 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 9 Jun 2026 10:44:32 +0200 Subject: [PATCH 3/3] Update bundler version --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6c84217..59664c4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,4 +254,4 @@ DEPENDENCIES slather BUNDLED WITH - 1.17.2 + 4.0.12