diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a976b0..f2c783a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,43 +8,43 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-15 name: Build and Test Swift Package steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Select Xcode Version - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '15.3.0' - + xcode-version: '26.3' + - name: Setup environment run: | bundle install - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator17.4 -destination "OS=17.4,name=iPhone 15 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - + PodLinting: - runs-on: macos-12 + runs-on: macos-15 name: Lint Podspec - + steps: - name: Checkout - uses: actions/checkout@v4 - + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: setup-cocoapods - uses: maxim-lobanov/setup-cocoapods@v1 + uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0 with: version: 1.15.2 - + - name: Run pod lint for Paystack Core run: pod lib lint PaystackCore.podspec --allow-warnings @@ -52,11 +52,11 @@ jobs: release: if: ${{ github.event.pull_request.merged == true && github.head_ref == 'release/update-versions' }} - runs-on: macos-12 + runs-on: macos-15 needs: [build, PodLinting] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get version information run: | @@ -67,12 +67,12 @@ jobs: - name: Create Release id: create_release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ env.version }} - release_name: ${{ env.version }} + name: ${{ env.version }} body: ${{ env.body }} - name: Install Cocoapods diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94d6e0d..0359b98 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,27 +5,27 @@ on: jobs: deploy: - runs-on: macos-14 + runs-on: macos-15 name: Deploy to Cocoapods Trunk steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Select Xcode Version - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '15.3.0' - + xcode-version: '26.3' + - name: Setup environment run: | bundle install - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator17.4 -destination "OS=17.4,name=iPhone 15 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO - + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + - name: setup-cocoapods - uses: maxim-lobanov/setup-cocoapods@v1 + uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0 with: version: 1.15.2 @@ -38,12 +38,12 @@ jobs: - name: Create Release id: create_release - uses: actions/create-release@v1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ env.version }} - release_name: ${{ env.version }} + name: ${{ env.version }} body: ${{ env.body }} Publish_Cocoapods: @@ -51,9 +51,9 @@ jobs: needs: deploy steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0 with: ruby-version: '3.2.2' @@ -65,4 +65,3 @@ jobs: pod trunk push PaystackUI.podspec --allow-warnings env: COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} - diff --git a/.github/workflows/primary.yml b/.github/workflows/primary.yml index adba04e..62b7c85 100644 --- a/.github/workflows/primary.yml +++ b/.github/workflows/primary.yml @@ -12,32 +12,32 @@ jobs: name: Code quality Checks steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Danger - uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.15.0 + uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.22.1 with: args: --failOnErrors --no-publish-check env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SwiftPackage: - runs-on: macos-14 + runs-on: macos-15 name: Build and Test Swift Package steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Select Xcode Version - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '15.3.0' - + xcode-version: '26.3' + - name: Setup environment run: | bundle install - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator17.4 -destination "OS=17.4,name=iPhone 15 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: @@ -46,15 +46,15 @@ jobs: PodLinting: if: ${{ false }} - runs-on: macos-12 + runs-on: macos-15 name: Lint Podspec - + steps: - name: Checkout - uses: actions/checkout@v4 - + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: setup-cocoapods - uses: maxim-lobanov/setup-cocoapods@v1 + uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0 with: version: 1.15.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ba9bb9..2850047 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,19 +16,19 @@ on: jobs: release: - runs-on: macos-14 + runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Increment Latest Version run: | mode=${{ github.event.inputs.release }} cd Sources/PaystackSDK sh Versioning/update_version.sh $mode ${{ github.event.inputs.body }} - + - name: Open Pull Request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: Updated versions in podspec and plist for release title: Updating SDK Version diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PaystackCore.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackCore.xcscheme new file mode 100644 index 0000000..6367c60 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackCore.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PaystackSDK-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackSDK-Package.xcscheme new file mode 100644 index 0000000..573c96b --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackSDK-Package.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PaystackUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackUI.xcscheme new file mode 100644 index 0000000..2cd1925 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PaystackUI.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Podfile b/Example/Podfile index 6a5016c..cca2182 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -14,6 +14,14 @@ post_install do |installer| project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0' + + # Expose TweetNacl's `CTweetNacl` C submodule to every pod target. + # Without this, Xcode 16+/Swift 6 explicit-module builds fail to + # resolve `CTweetNacl` when PusherSwift (transitively) imports TweetNacl. + existing = config.build_settings['SWIFT_INCLUDE_PATHS'] || '$(inherited)' + unless existing.include?('TweetNacl/Sources') + config.build_settings['SWIFT_INCLUDE_PATHS'] = "#{existing} \"${PODS_ROOT}/TweetNacl/Sources\"" + end end end end diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 24839af..08c335c 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -34,4 +34,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: b54fdf55a0051a16e75a4e0e52ebaa1f84dbf923 -COCOAPODS: 1.11.3 +COCOAPODS: 1.14.2 diff --git a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj index e5936ac..e417584 100644 --- a/Example/paystack-sdk-ios.xcodeproj/project.pbxproj +++ b/Example/paystack-sdk-ios.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 17DA2B6029C32CE800452587 /* Frameworks */, 17DA2B6129C32CE800452587 /* Resources */, A9E4DDA83366D10F901214BF /* [CP] Embed Pods Frameworks */, + 7391A8FC2B10B6E600CE5BBD /* SwiftLint */, ); buildRules = ( ); @@ -161,6 +162,24 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 7391A8FC2B10B6E600CE5BBD /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nif which swiftlint >/dev/null; then\n swiftlint --fix\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; A9E4DDA83366D10F901214BF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/Package.swift b/Package.swift index 211843a..7da9c31 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,9 @@ let package = Package( resources: [ .copy("API/Transactions/Resources/VerifyAccessCode.json"), .copy("API/Charge/Resources/ChargeAuthenticationResponse.json"), - .copy("API/Other/Resources/AddressStatesResponse.json") + .copy("API/Other/Resources/AddressStatesResponse.json"), + .copy("API/Charge/Resources/ChargeMobileMoneyResponse.json") + ]) ] ) diff --git a/Sources/PaystackSDK/API/Charge/Charge.swift b/Sources/PaystackSDK/API/Charge/Charge.swift index 2bb30e4..3a8dc33 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length type_body_length line_length import Foundation public extension Paystack { @@ -5,6 +6,10 @@ public extension Paystack { return ChargeServiceImplementation(config: config) } + private var mobileMoneyService: MobileMoneyService { + return MobileMoneyServiceImplementation(config: config) + } + /// Continues the Charge flow by authenticating a user with an OTP /// - Parameters: /// - otp: The OTP sent to the user's device @@ -73,4 +78,22 @@ public extension Paystack { return Service(subscription) } + /// Listens for a response after presenting a 3DS URL in a webview for authentication + /// - Parameter transactionId:The ID of the current transaction that is being authenticated + /// - Returns: A ``Service`` with the results of the authentication + func listenForMobileMoneyResponse(for transactionId: Int) -> Service { + let channelName = "MOBILE_MONEY_\(transactionId)" + let subscription: any Subscription = PusherSubscription(channelName: channelName, eventName: "response") + return Service(subscription) + } + + /// Initialize Mobile Money charge + /// - Parameters: + /// - mobileMoneyData: The data that needs to be passed in order to do a mobile money charge + /// - Returns: A ``Service`` with the ``MobileMoneyChargeResponse`` response + func chargeMobileMoney(with mobileMoneyData: MobileMoneyData) -> Service { + let request = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_\(mobileMoneyData.transaction)", phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) + return mobileMoneyService.postChargeMobileMoney(request) + } } +// swiftlint:enable file_length type_body_length line_length diff --git a/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift new file mode 100644 index 0000000..e2fc647 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift @@ -0,0 +1,19 @@ +import Foundation + +protocol MobileMoneyService: PaystackService { + func postChargeMobileMoney(_ request: MobileMoneyChargeRequest) -> Service +} + +struct MobileMoneyServiceImplementation: MobileMoneyService { + + var config: PaystackConfig + + var parentPath: String { + return "charge" + } + + func postChargeMobileMoney(_ request: MobileMoneyChargeRequest) -> Service { + return post("/mobile_money", request) + .asService() + } +} diff --git a/Sources/PaystackSDK/Core/Models/MobileMoney.swift b/Sources/PaystackSDK/Core/Models/MobileMoney.swift new file mode 100644 index 0000000..e73d6e6 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/MobileMoney.swift @@ -0,0 +1,16 @@ +import Foundation + +// MARK: - MobileMoney +public struct MobileMoney: Codable { + public let key: String + public let value: String + public let isNew: Bool + public let phoneNumberRegex: String + + enum CodingKeys: String, CodingKey { + case key + case value + case isNew + case phoneNumberRegex + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/Channel.swift b/Sources/PaystackSDK/Core/Models/Models/Channel.swift index 2f67596..efbd007 100644 --- a/Sources/PaystackSDK/Core/Models/Models/Channel.swift +++ b/Sources/PaystackSDK/Core/Models/Models/Channel.swift @@ -11,6 +11,7 @@ public enum Channel: String, Codable { case card = "card" case bank = "bank" case ussd = "ussd" + case mobileMoney = "mobile_money" case qr = "qr" case bankTransfer = "bank_transfer" case unsupportedChannel diff --git a/Sources/PaystackSDK/Core/Models/Models/Charge/Charge3DSResponseStatus.swift b/Sources/PaystackSDK/Core/Models/Models/Charge/Charge3DSResponseStatus.swift index 665e0bc..dc831b4 100644 --- a/Sources/PaystackSDK/Core/Models/Models/Charge/Charge3DSResponseStatus.swift +++ b/Sources/PaystackSDK/Core/Models/Models/Charge/Charge3DSResponseStatus.swift @@ -3,4 +3,5 @@ import Foundation public enum Charge3DSResponseStatus: String, Decodable { case success case failed + case zeroFailed = "0" } diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift new file mode 100644 index 0000000..475f88f --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +// MARK: - MobileMoneyChargeRequest +public struct MobileMoneyChargeRequest: Codable { + let channelName: String + let phone: String + let transaction: String + let provider: String +} diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift new file mode 100644 index 0000000..4e7995c --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -0,0 +1,32 @@ +import Foundation + +// MARK: - MobileMoneyChargeResponse +public struct MobileMoneyChargeResponse: Codable { + public let status: Bool + public let message: String + public let data: MobileMoneyChargeData +} + +// MARK: - MobileMoneyChargeData +public struct MobileMoneyChargeData: Codable { + public let transaction: String + public let phone: String + public let provider: String + public let channelName: String + public let display: Display + + enum CodingKeys: String, CodingKey { + case transaction + case phone + case provider + case channelName + case display + } +} + +// MARK: - Display +public struct Display: Codable { + public let type: String + public let message: String + public let timer: Int +} diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift new file mode 100644 index 0000000..e37a27c --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct MobileMoneyData: Equatable { + let phone: String + let transaction: String + let provider: String + + public init(phone: String, transaction: String, provider: String) { + self.phone = phone + self.transaction = transaction + self.provider = provider + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift index c7a8a29..01f7735 100644 --- a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift @@ -5,16 +5,19 @@ public struct ChannelOptions: Codable { public var bankTransfer: [String]? public var ussd: [String]? public var qrCode: [String]? + public var mobileMoney: [MobileMoney]? - public init(bankTransfer: [String]? = nil, ussd: [String]? = nil, qrCode: [String]? = nil) { + public init(bankTransfer: [String]? = nil, ussd: [String]? = nil, qrCode: [String]? = nil, mobileMoney: [MobileMoney]? = nil) { self.bankTransfer = bankTransfer self.ussd = ussd self.qrCode = qrCode + self.mobileMoney = mobileMoney } enum CodingKeys: String, CodingKey { case ussd case qrCode = "qr" case bankTransfer = "bank_transfer" + case mobileMoney } } diff --git a/Sources/PaystackSDK/Core/Service/PaystackService.swift b/Sources/PaystackSDK/Core/Service/PaystackService.swift index 777e30c..0e945a7 100644 --- a/Sources/PaystackSDK/Core/Service/PaystackService.swift +++ b/Sources/PaystackSDK/Core/Service/PaystackService.swift @@ -8,7 +8,7 @@ public protocol PaystackService: URLRequestBuilderHelper { public extension PaystackService { var endpoint: String { - return "https://api.paystack.co/\(parentPath)" + return "https://api.paystack.co/\(parentPath)" } var bearerToken: String { diff --git a/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift b/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift index 736c2bb..f25f55f 100644 --- a/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift +++ b/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift @@ -35,7 +35,6 @@ struct PusherSubscriptionListener: SubscriptionListener { let channel = pusher.subscribe(channelName: channelName) bindConnectionEvents(to: channel) pusher.connect() - listenForData(on: channel, forEvent: eventName, subscriptionResponse: completion) } @@ -53,7 +52,6 @@ struct PusherSubscriptionListener: SubscriptionListener { on channel: PusherChannel, forEvent eventName: String, subscriptionResponse: @escaping (Result) -> Void ) { - channel.bind(eventName: eventName, eventCallback: { guard let stringData = $0.data else { subscriptionResponse(.failure(.noData)) diff --git a/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift b/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift index c497a51..9e3eda5 100644 --- a/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift +++ b/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift @@ -16,4 +16,5 @@ extension URLRequestBuilder { return addHeader("X-Paystack-User-Agent", agentString) } + } diff --git a/Sources/PaystackUI/Charge/ChargeCard/Container/ChargeCardViewModel.swift b/Sources/PaystackUI/Charge/ChargeCard/Container/ChargeCardViewModel.swift index 559fc18..efd16f9 100644 --- a/Sources/PaystackUI/Charge/ChargeCard/Container/ChargeCardViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeCard/Container/ChargeCardViewModel.swift @@ -1,7 +1,7 @@ import Foundation import PaystackCore -class ChargeCardViewModel: ObservableObject, ChargeCardContainer { +class ChargeCardViewModel: ObservableObject, @MainActor ChargeCardContainer { @Published var chargeCardState: ChargeCardState diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index b9bbd83..85509e5 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -36,6 +36,10 @@ struct ChargeView: View { case .success(let amount, let merchant, let details): ChargeSuccessView(amount: amount, merchant: merchant, completionDetails: details) + case .channelSelection(let transactionInformation, let supportedChannels): + ChannelSelectionView(state: $viewModel.transactionState, + supportedChannels: supportedChannels, + information: transactionInformation) } Spacer() @@ -55,6 +59,8 @@ struct ChargeView: View { case .card(let transactionInformation): ChargeCardView(transactionDetails: transactionInformation, chargeContainer: viewModel) + case .mobileMoney(transactionInformation: let transactionInformation): + MPesaChargeView(chargeCardContainer: viewModel, transactionDetails: transactionInformation) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 063291f..98e2277 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import PaystackCore class ChargeViewModel: ObservableObject { @@ -19,19 +20,36 @@ class ChargeViewModel: ObservableObject { @MainActor func verifyAccessCodeAndProceedWithCard() async { + var supportedChannels: [SupportedChannels] = [] do { transactionState = .loading() let accessCodeResponse = try await repository.verifyAccessCode(accessCode) - guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card }) else { - let message = "Card payments are not supported. " + + guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card || $0 == .mobileMoney }) else { + 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" throw ChargeError(displayMessage: message, causeMessage: cause) } - self.transactionDetails = accessCodeResponse - transactionState = .payment(type: .card(transactionInformation: accessCodeResponse)) + 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)) + } } catch { let error = ChargeError(error: error) Logger.error("Verify access code failed with error: %@", @@ -43,6 +61,24 @@ class ChargeViewModel: ObservableObject { } +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") + } + } + +} + // MARK: - Charge Container extension ChargeViewModel: ChargeContainer { diff --git a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift new file mode 100644 index 0000000..78e4cde --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift @@ -0,0 +1,22 @@ +import Foundation +import PaystackCore + +struct MobileMoneyChannel: Equatable { + let key: String + let value: String + let isNew: Bool + let phoneNumberRegex: String +} + +extension MobileMoneyChannel { + + static func from(_ response: MobileMoney) -> Self { + return MobileMoneyChannel(key: response.key, value: response.value, isNew: response.isNew, phoneNumberRegex: response.phoneNumberRegex) + } +} + +extension MobileMoneyChannel { + static var example: Self { + .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}$") + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift new file mode 100644 index 0000000..1ae0708 --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift @@ -0,0 +1,24 @@ +import Foundation +import PaystackCore + +struct MobileMoneyTransaction: Equatable { + let transaction: String + let phone: String + let provider: String + let channelName: String + let timer: Int + let message: String +} + +extension MobileMoneyTransaction { + + static func from(_ response: MobileMoneyChargeResponse) -> Self { + MobileMoneyTransaction(transaction: response.data.transaction, + phone: response.data.phone, + provider: response.data.provider, + channelName: response.data.channelName, + timer: response.data.display.timer, + message: response.data.display.message) + } + +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift b/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift new file mode 100644 index 0000000..5603ccf --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift @@ -0,0 +1,33 @@ +import Foundation +import PaystackCore + +protocol ChargeMobileMoneyRepository { + func chargeMobileMoney(phone: String, transactionId: String, provider: String) async throws -> MobileMoneyTransaction + func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction +} + +struct ChargeMobileMoneyRepositoryImplementation: ChargeMobileMoneyRepository { + + let paystack: Paystack + + init() { + self.paystack = PaystackContainer.instance.retrieve() + } + + func chargeMobileMoney(phone: String, transactionId: String, provider: String) async throws -> MobileMoneyTransaction { + let request = MobileMoneyData(phone: phone, transaction: transactionId, provider: provider) + let response = try await paystack.chargeMobileMoney(with: request).async() + return MobileMoneyTransaction.from(response) + } + + func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction { + let response = try await paystack.listenForMobileMoneyResponse(for: transactionId).async() + return ChargeCardTransaction.from(response) + } + + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction { + let response = try await paystack.checkPendingCharge(forAccessCode: accessCode).async() + return ChargeCardTransaction.from(response) + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift new file mode 100644 index 0000000..3299a15 --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -0,0 +1,89 @@ +import Foundation +import PaystackCore + +protocol MPesaContainer { + var transactionDetails: VerifyAccessCode { get } + func processTransactionResponse(_ response: ChargeCardTransaction) async + func displayTransactionError(_ error: ChargeError) + func restartMPesaPayment() +} + +class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { + + var chargeCardContainer: ChargeContainer + var repository: ChargeMobileMoneyRepository + var transactionDetails: VerifyAccessCode + @Published + var phoneNumber: String = "" + + @Published + var transactionState: ChargeMobileMoneyState = .countdown + + init(chargeCardContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { + self.chargeCardContainer = chargeCardContainer + self.repository = repository + self.transactionDetails = transactionDetails + } + + var isValid: Bool { + phoneNumber.count >= 10 + } + + @MainActor + func submitPhoneNumber() async { + do { + let authenticationResult = try await repository.chargeMobileMoney( + phone: phoneNumber.formattedKenyanPhoneNumber, + transactionId: "\(transactionDetails.transactionId ?? 0)", + provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") + transactionState = .processTransaction(transaction: authenticationResult) + } catch { + displayTransactionError(ChargeError(error: error)) + } + } + + func restartMPesaPayment() { + transactionState = .countdown + } + + @MainActor + func processTransactionResponse(_ response: ChargeCardTransaction) async { + switch response.status { + case .success: + chargeCardContainer.processSuccessfulTransaction(details: transactionDetails) + case .failed: + let message = response.message ?? response.displayText ?? "Declined" + transactionState = .error(ChargeError(message: message)) + case .timeout: + let message = response.displayText ?? "Payment timed out" + transactionState = .fatalError(error: .init(message: message)) + case .pending: + break + default: + Logger.error("Unexpected M-Pesa transaction status: %@", + arguments: response.status.rawValue) + transactionState = .fatalError( + error: .generic(withCause: "Unexpected transaction status: \(response.status.rawValue)")) + } + } + + @MainActor + func displayTransactionError(_ error: ChargeError) { + Logger.error("Displaying error: %@", arguments: error.localizedDescription) + transactionState = .error(error) + } + + func cancelTransaction() { + restartMPesaPayment() + } +} + +enum ChargeMobileMoneyState { + case loading(message: String? = nil) + case countdown + case error(ChargeError) + case fatalError(error: ChargeError) + case processTransaction(transaction: MobileMoneyTransaction) +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift new file mode 100644 index 0000000..f8613b8 --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import PaystackCore + +class MPesaProcessingViewModel: ObservableObject { + + var container: MPesaContainer + var repository: ChargeMobileMoneyRepository + let mobileMoneyTransaction: MobileMoneyTransaction + @Published + var counter = 0 + + init(container: MPesaContainer, + mobileMoneyTransaction: MobileMoneyTransaction, + repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { + self.container = container + self.repository = repository + self.mobileMoneyTransaction = mobileMoneyTransaction + } + + var transactionDetails: VerifyAccessCode { + container.transactionDetails + } + + func checkTransactionStatus() { + Task { + await checkPendingCharge() + } + } + + @MainActor + func initializeMPesaAuthorization() async { + do { + let authenticationResult = try await repository.listenForMPesa( + for: Int(mobileMoneyTransaction.transaction) ?? 0) + await container.processTransactionResponse(authenticationResult) + } catch { + Logger.error("Listening for M-Pesa transaction failed with error: %@", + arguments: error.localizedDescription) + container.displayTransactionError(ChargeError(error: error)) + } + } + + @MainActor + private func checkPendingCharge() async { + do { + let authenticationResult = try await repository.checkPendingCharge( + with: transactionDetails.accessCode) + await container.processTransactionResponse(authenticationResult) + } catch { + Logger.error("Checking pending M-Pesa charge failed with error: %@", + arguments: error.localizedDescription) + container.displayTransactionError(ChargeError(error: error)) + } + } + + func cancelTransaction() { + container.restartMPesaPayment() + } + +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift new file mode 100644 index 0000000..c5731bb --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -0,0 +1,114 @@ +// +// ChannelSelectionView.swift +// PaystackUI +// +// Created by Peter-John Welcome on 2023/12/01. +// + +import SwiftUI +import PaystackCore +@available(iOS 14.0, *) +struct ChannelSelectionView: View { + + @EnvironmentObject + var visibilityContainer: ViewVisibilityContainer + @StateObject + var viewModel: ChannelSelectionViewModel + let columns = [GridItem(.flexible()), GridItem(.flexible())] + var items: [PaymentChannel] = [] + init(state: Binding, + supportedChannels: [SupportedChannels], + information: VerifyAccessCode) { + self._viewModel = StateObject(wrappedValue: ChannelSelectionViewModel(state: state, information: information)) + items = supportedChannels.map { + PaymentChannel(channel: $0) + } + } + + var body: some View { + ScrollView { + VStack(spacing: .triplePadding) { + + Text("Choose a payment method to continue") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + GeometryReader { geo in + LazyVGrid(columns: columns) { + ForEach(items) { value in + ChannelView(channelTitle: value.title, image: value.image) + .padding(.singlePadding) + .onTapGesture { + viewModel.chooseChannel(channel: value.channel) + } + .frame(width: (geo.size.width / CGFloat(items.count)).rounded()) + } + } + } + } + } + } +} + +class ChannelSelectionViewModel: ObservableObject { + @Binding + var state: ChargeState + private let information: VerifyAccessCode + init(state: Binding, information: VerifyAccessCode) { + self._state = state + 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" + switch channel { + 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)) + } + + } +} + +struct PaymentChannel: Identifiable { + var id: String = UUID().uuidString + let channel: SupportedChannels + var title: String { + channel.rawValue + } + var image: Image { + channel.image + } +} + +struct ChannelView: View { + + let channelTitle: String + let image: Image + + var body: some View { + + HStack(spacing: .singlePadding) { + + VStack(alignment: .leading, spacing: .singlePadding) { + image.frame(width: 20, height: 20) + Text(channelTitle) + .font(.body14M) + .foregroundColor(.navy02) + } + Spacer() + } + .padding(.doublePadding) + .cornerRadius(.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: .cornerRadius) + .stroke(Color.navy05, lineWidth: 1) + ) + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift new file mode 100644 index 0000000..04239cc --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct MPesaChargeView: View { + + @StateObject + var viewModel: MPesaChrageViewModel + private let phoneNumberMaximumLength = 15 + @State private var showPhoneNumberError = false + + init( + chargeCardContainer: ChargeContainer, + transactionDetails: VerifyAccessCode) { + self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel( + chargeCardContainer: chargeCardContainer, transactionDetails: transactionDetails)) + } + + var body: some View { + switch viewModel.transactionState { + case .loading(let message): + LoadingView(message: message) + case .error(let chargeError): + ErrorView(message: chargeError.message, + buttonText: "Try again", + buttonAction: viewModel.restartMPesaPayment) + 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) + case .countdown: + VStack(spacing: .triplePadding) { + + Text("Please enter your mobile money number to begin this payment") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + FormInput(title: "Pay \(viewModel.transactionDetails.amountCurrency.description)", + enabled: viewModel.isValid, + action: viewModel.submitPhoneNumber, + secondaryAction: viewModel.cancelTransaction) { + phoneNumber + } + } + .padding(.doublePadding) + } + } + + @ViewBuilder + var phoneNumber: some FormInputItemView { + TextFieldFormInputView(title: "Phone Number", + placeholder: "070 000 0000", + text: $viewModel.phoneNumber, + keyboardType: .phonePad, + maxLength: phoneNumberMaximumLength, + inErrorState: $showPhoneNumberError, + defaultFocused: true, + accessoryView: Image.kenyaFlagLogo) + .minLength(10, errorMessage: "Invalid Phone Number") + } +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift new file mode 100644 index 0000000..5d6e54e --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct MPesaProcessingView: View { + + @StateObject + var viewModel: MPesaProcessingViewModel + + init(container: MPesaContainer, + mobileMoneyTransaction: MobileMoneyTransaction) { + self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel( + container: container, + mobileMoneyTransaction: mobileMoneyTransaction)) + } + + var body: some View { + + VStack(spacing: .triplePadding) { + + Image.otpIcon + Text(viewModel.mobileMoneyTransaction.phone) + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + Text("Please enter your pin on your phone to complete this payment") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + CountdownView(counter: $viewModel.counter, + countTo: viewModel.mobileMoneyTransaction.timer, + action: viewModel.checkTransactionStatus) + } + .padding(.doublePadding) + .task(viewModel.initializeMPesaAuthorization) + } +} + +#Preview { + CountdownView(counter: Binding.constant(0)) +} + +let timer = Timer + .publish(every: 1, on: .main, in: .common) + .autoconnect() + +struct Clock: View { + var counter: Int + var countTo: Int + + var body: some View { + VStack { + Image.messageBubbleLogo + + } + } +} + +struct ProgressTrack: View { + var body: some View { + Circle() + .fill(Color.clear) + .frame(width: 35, height: 35) + .overlay( + Circle().stroke(Color.gray01, lineWidth: 5) + ) + } +} + +struct ProgressBar: View { + var counter: Int + var countTo: Int + + var body: some View { + Circle() + .fill(Color.clear) + .frame(width: 35, height: 35) + .overlay( + Circle().trim(from: 0, to: progress()) + .stroke( + style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + lineJoin: .round + ) + ) + .foregroundColor( + (completed() ? Color.stackGreen : Color.stackGreen) + ).animation( + .easeInOut(duration: 0.2) + ) + ) + } + + func completed() -> Bool { + return progress() == 1 + } + + func progress() -> CGFloat { + return (CGFloat(counter) / CGFloat(countTo)) + } +} + +struct CountdownView: View { + @Binding var counter: Int + var countTo: Int = 60 + var action: () -> Void = {} + var body: some View { + VStack { + ZStack { + ProgressTrack() + ProgressBar(counter: counter, countTo: countTo) + Clock(counter: counter, countTo: countTo) + } + Text("Payment is valid for \(counterToMinutes())") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + }.onReceive(timer) { _ in + if self.counter < self.countTo { + self.counter += 1 + } else if self.counter == self.countTo { + action() + } + } + } + + func counterToMinutes() -> String { + let currentTime = countTo - counter + let seconds = currentTime % 60 + let minutes = Int(currentTime / 60) + return "\(minutes):\(seconds < 10 ? "0" : "")\(seconds)" + } +} diff --git a/Sources/PaystackUI/Charge/Models/ChannelOptions.swift b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift new file mode 100644 index 0000000..7e49cf4 --- /dev/null +++ b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift @@ -0,0 +1,21 @@ +import Foundation +import PaystackCore + +struct ChannelOptions: Equatable { + var mobileMoney: [MobileMoneyChannel]? +} + +extension ChannelOptions { + + static func from(_ response: PaystackCore.ChannelOptions) -> Self { + return ChannelOptions(mobileMoney: response.mobileMoney?.map({ + MobileMoneyChannel.from($0) + })) + } +} + +extension ChannelOptions { + static var example: Self { + .init(mobileMoney: [.example]) + } +} diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index 05a196d..61b82a8 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -3,4 +3,5 @@ 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) } diff --git a/Sources/PaystackUI/Charge/Models/ChargeState.swift b/Sources/PaystackUI/Charge/Models/ChargeState.swift index 65d2f77..62e4130 100644 --- a/Sources/PaystackUI/Charge/Models/ChargeState.swift +++ b/Sources/PaystackUI/Charge/Models/ChargeState.swift @@ -1,9 +1,10 @@ import Foundation - +import PaystackCore // TODO: Add state for select payment channel enum ChargeState { case loading(message: String? = nil) case payment(type: ChargePaymentType) + case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannels]) case error(ChargeError) case success(amount: AmountCurrency, merchant: String, details: ChargeCompletionDetails) diff --git a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift index 23a2ccb..4c42a10 100644 --- a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift +++ b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift @@ -11,6 +11,7 @@ struct VerifyAccessCode: Equatable { var publicEncryptionKey: String var reference: String var transactionId: Int? + var channelOptions: PaystackUI.ChannelOptions? var amountCurrency: AmountCurrency { AmountCurrency(amount: amount, currency: currency) @@ -28,7 +29,7 @@ extension VerifyAccessCode { merchantName: response.data.merchantName, publicEncryptionKey: response.data.publicEncryptionKey, reference: response.data.reference, - transactionId: response.data.id) + transactionId: response.data.id, channelOptions: PaystackUI.ChannelOptions.from(response.data.channelOptions)) } } @@ -43,6 +44,6 @@ extension VerifyAccessCode { domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: PaystackUI.ChannelOptions.example) } } diff --git a/Sources/PaystackUI/Components/Styles/PrimaryButtonStyle.swift b/Sources/PaystackUI/Components/Styles/PrimaryButtonStyle.swift index 7453a5e..eae42c0 100644 --- a/Sources/PaystackUI/Components/Styles/PrimaryButtonStyle.swift +++ b/Sources/PaystackUI/Components/Styles/PrimaryButtonStyle.swift @@ -61,3 +61,66 @@ struct PrimaryButtonStyle_Previews: PreviewProvider { .padding() } } + +@available(iOS 14.0, *) +struct PaymentChannelSelectionButton: ButtonStyle { + + @Environment(\.isEnabled) + var isEnabled + + var showLoading: Bool + + init(showLoading: Bool = false) { + self.showLoading = showLoading + } + + func makeBody(configuration: Configuration) -> some View { + VStack { + if showLoading { + LoadingIndicator(tintColor: foreground) + } else { + configuration.label + } + } + .padding() + .frame(height: .buttonHeight) + .frame(maxWidth: .infinity) + .focusedBorderColor(defaultColor: .gray01) + .font(.body16M) + .background(configuration.isPressed ? pressedBackground : background) + .foregroundColor(configuration.isPressed ? pressedForeground : foreground) + .cornerRadius(.cornerRadius) + .disabled(showLoading) + } + +} + +@available(iOS 14.0, *) +extension PaymentChannelSelectionButton { + + var background: Color { + isEnabled ? .stackGreen : .stackGreen.opacity(0.6) + } + + var pressedBackground: Color { + .stackGreen.opacity(0.75) + } + + var foreground: Color { + .white + } + + var pressedForeground: Color { + .white.opacity(0.75) + } + +} + +@available(iOS 14.0, *) +struct PaymentChannelSelectionButton_Previews: PreviewProvider { + static var previews: some View { + Button("Example", action: {}) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + .padding() + } +} diff --git a/Sources/PaystackUI/Images/Images.swift b/Sources/PaystackUI/Images/Images.swift index fef2c29..9e92a95 100644 --- a/Sources/PaystackUI/Images/Images.swift +++ b/Sources/PaystackUI/Images/Images.swift @@ -37,6 +37,26 @@ extension Image { .frame(height: 16) } + static var kenyaFlagLogo: some View { + Image("kenyaFlagLogo", bundle: .current) + .frame(height: 16) + } + + static var messageBubbleLogo: some View { + Image("messageBubbleLogo", bundle: .current) + .frame(height: 16) + } + + static var kenyaShLogo: some View { + Image("kenyaSHLogo", bundle: .current) + .frame(height: 16) + } + + static var cardLogo: some View { + Image("cardLogo", bundle: .current) + .frame(height: 16) + } + static var paystackSecured: some View { Image("paystackSecured", bundle: .current) .resizable() diff --git a/Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Contents.json new file mode 100644 index 0000000..7db2264 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Selected.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/cardLogo.imageset/Selected.png b/Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Selected.png new file mode 100644 index 0000000..8fecfd7 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Selected.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Contents.json new file mode 100644 index 0000000..42fbc55 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.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/kenyaFlagLogo.imageset/Flags.png b/Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Flags.png new file mode 100644 index 0000000..baa96d0 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Flags.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Contents.json new file mode 100644 index 0000000..4dc596e --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Ksh.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/kenyaSHLogo.imageset/Ksh.png b/Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Ksh.png new file mode 100644 index 0000000..ef00336 Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Ksh.png differ diff --git a/Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/Contents.json new file mode 100644 index 0000000..c397fd5 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bubble.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/messageBubbleLogo.imageset/bubble.png b/Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/bubble.png new file mode 100644 index 0000000..7e1c4da Binary files /dev/null and b/Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/bubble.png differ diff --git a/Sources/PaystackUI/Utils/StringExtensions.swift b/Sources/PaystackUI/Utils/StringExtensions.swift index 9086c66..5fb01f9 100644 --- a/Sources/PaystackUI/Utils/StringExtensions.swift +++ b/Sources/PaystackUI/Utils/StringExtensions.swift @@ -11,4 +11,18 @@ extension String { timeZone: timeZone, from: self) } + + var formattedKenyanPhoneNumber: String { + let trimmed = self.removingAllWhitespaces + if trimmed.hasPrefix("+254") { + return trimmed + } + if trimmed.hasPrefix("254") { + return "+" + trimmed + } + if trimmed.hasPrefix("0") { + return "+254" + trimmed.dropFirst() + } + return trimmed + } } diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index 1509acf..fc2cf67 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -1,40 +1,60 @@ +// swiftlint:disable file_length type_body_length line_length import XCTest @testable import PaystackCore final class ChargeTests: PSTestCase { - + let apiKey = "testsk_Example" - + var serviceUnderTest: Paystack! - + override func setUpWithError() throws { try super.setUpWithError() serviceUnderTest = try PaystackBuilder.newInstance .setKey(apiKey) .build() } - + // TODO: testAuthenticateChargeWithOTPAuthentication - + // TODO: testAuthenticateChargeWithPhoneAuthentication - - // TODO: Add Test for testAuthenticateChargeWithBirthdayAuthentication - - // TODO: Add test for testAuthenticateChargeWithAddressAuthentication - - func testListenFor3DS() throws { - let transactionId = 1234 - let mockSubscription = PusherSubscription(channelName: "3DS_\(transactionId)", - eventName: "response") - - // swiftlint:disable:next line_length - let responseString = "{\"redirecturl\":\"?trxref=2wdckavunc&reference=2wdckavunc\",\"trans\":\"1234\",\"trxref\":\"2wdckavunc\",\"reference\":\"2wdckavunc\",\"status\":\"success\",\"message\":\"Success\",\"response\":\"Approved\"}" - - mockSubscriptionListener - .expectSubscription(mockSubscription) - .andReturnString(responseString) - - _ = try serviceUnderTest.listenFor3DSResponse(for: transactionId).sync() + + func testAuthenticateChargeWithPhoneAuthentication() throws { + let phoneRequestBody = SubmitPhoneRequest(phone: "0111234567", accessCode: "abcde") + // TODO: Add Test for testAuthenticateChargeWithBirthdayAuthentication + + // TODO: Add test for testAuthenticateChargeWithAddressAuthentication + + func testListenFor3DS() throws { + let transactionId = 1234 + let mockSubscription = PusherSubscription(channelName: "3DS_\(transactionId)", + eventName: "response") + + // swiftlint:disable:next line_length + let responseString = "{\"redirecturl\":\"?trxref=2wdckavunc&reference=2wdckavunc\",\"trans\":\"1234\",\"trxref\":\"2wdckavunc\",\"reference\":\"2wdckavunc\",\"status\":\"success\",\"message\":\"Success\",\"response\":\"Approved\"}" + + mockSubscriptionListener + .expectSubscription(mockSubscription) + .andReturnString(responseString) + + _ = try serviceUnderTest.listenFor3DSResponse(for: transactionId).sync() + } + + func testListenForMobileMoney() throws { + let transactionId = 1234 + let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", + eventName: "response") + + // swiftlint:disable:next line_length + let responseString = "{\"redirecturl\":\"?trxref=2wdckavunc&reference=2wdckavunc\",\"trans\":\"1234\",\"trxref\":\"2wdckavunc\",\"reference\":\"2wdckavunc\",\"status\":\"success\",\"message\":\"Success\",\"response\":\"Approved\"}" + + mockSubscriptionListener + .expectSubscription(mockSubscription) + .andReturnString(responseString) + + _ = try serviceUnderTest.listenForMobileMoneyResponse(for: transactionId).sync() + } + } - + // swiftlint:enable file_length type_body_length line_length } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json new file mode 100644 index 0000000..ba39200 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json @@ -0,0 +1,15 @@ +{ + "status": true, + "message": "Charge attempted", + "data": { + "transaction": "1504248187", + "phone": "0703362111", + "provider": "MPESA", + "channel_name": "MOBILE_MONEY_1504248187", + "display": { + "type": "pop", + "message": "Please complete authorization process on your mobile phone", + "timer": 60 + } + } +} diff --git a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json index d0dafd2..aea7d2d 100644 --- a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json +++ b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json @@ -15,12 +15,27 @@ "card", "qr", "ussd", - "eft" + "eft", + "mobile_money" ], "channel_options": { "qr": [ "visa" ], + "mobile_money": [ + { + "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}$" + }, + { + "key": "MPESA_OFF", + "value": "M-PESA", + "isNew": false, + "phoneNumberRegex": "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$" + } + ], "ussd": [ "737", "822", diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeCard/ChargeCardViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeCard/ChargeCardViewModelTests.swift index 9f761fc..8c68e0e 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeCard/ChargeCardViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeCard/ChargeCardViewModelTests.swift @@ -32,7 +32,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .live, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest = ChargeCardViewModel(transactionDetails: transactionDetails, chargeContainer: mockChargeContainer, repository: mockRepository) @@ -48,7 +48,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest = ChargeCardViewModel(transactionDetails: transactionDetails, chargeContainer: mockChargeContainer, repository: mockRepository) @@ -64,7 +64,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest = ChargeCardViewModel(transactionDetails: transactionDetails, chargeContainer: mockChargeContainer, repository: mockRepository) @@ -78,7 +78,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .live, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest = ChargeCardViewModel(transactionDetails: transactionDetails, chargeContainer: mockChargeContainer, repository: mockRepository) @@ -92,7 +92,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .live, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest.switchToTestModeCardSelection() XCTAssertEqual(serviceUnderTest.chargeCardState, .testModeCardSelection(amount: transactionDetails.amountCurrency, @@ -196,7 +196,7 @@ final class ChargeCardViewModelTests: PSTestCase { merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", reference: "test_reference", - transactionId: expectedTransactionId) + transactionId: expectedTransactionId, channelOptions: .example) serviceUnderTest.transactionDetails = transactionDetails let redirectResponse = ChargeCardTransaction(status: .openUrl, @@ -215,7 +215,7 @@ final class ChargeCardViewModelTests: PSTestCase { merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", reference: "test_reference", - transactionId: expectedTransactionId) + transactionId: expectedTransactionId, channelOptions: .example) serviceUnderTest.transactionDetails = transactionDetails let redirectResponse = ChargeCardTransaction(status: .openUrl) @@ -232,7 +232,7 @@ final class ChargeCardViewModelTests: PSTestCase { paymentChannels: [], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) serviceUnderTest.transactionDetails = transactionDetails let redirectResponse = ChargeCardTransaction(status: .openUrl, diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 2e4ca1e..99e1f78 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -23,14 +23,19 @@ final class ChargeRepositoryImplementationTests: PSTestCase { .andReturn(json: "VerifyAccessCode") let result = try await serviceUnderTest.verifyAccessCode("access_code_test") + let phoneNumberRegex = "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$" let expectedResult = VerifyAccessCode(amount: 10000, currency: "NGN", accessCode: "Access_Code_Test", - paymentChannels: [.card, .qr, .ussd], + paymentChannels: [.card, .qr, .ussd, .mobileMoney], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "203520101") + reference: "203520101", + channelOptions: PaystackUI.ChannelOptions(mobileMoney: [ + .init(key: "MPESA", value: "M-PESA", isNew: true, phoneNumberRegex: phoneNumberRegex), + .init(key: "MPESA_OFF", value: "M-PESA", isNew: false, phoneNumberRegex: phoneNumberRegex) + ])) XCTAssertEqual(result, expectedResult) } diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 10d3400..779c1b7 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -13,10 +13,19 @@ final class ChargeViewModelTests: PSTestCase { } func testVerifyAccessCodeSetsViewStateAsCardDetailsWhenSuccessful() async { - mockRepo.expectedVerifyAccessCode = .example + let cardOnlyAccessCode = VerifyAccessCode(amount: 10000, + currency: "USD", + accessCode: "test_access", + paymentChannels: [.card], + domain: .test, + merchantName: "Test Merchant", + publicEncryptionKey: "test_encryption_key", + reference: "test_reference", + channelOptions: nil) + mockRepo.expectedVerifyAccessCode = cardOnlyAccessCode await serviceUnderTest.verifyAccessCodeAndProceedWithCard() XCTAssertEqual(serviceUnderTest.transactionState, - .payment(type: .card(transactionInformation: .example))) + .payment(type: .card(transactionInformation: cardOnlyAccessCode))) } func testVerifyAccessCodeSetsViewStateAsErrorWhenUnsuccessful() async { @@ -35,7 +44,7 @@ final class ChargeViewModelTests: PSTestCase { domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) await serviceUnderTest.verifyAccessCodeAndProceedWithCard() XCTAssertEqual(serviceUnderTest.transactionState, .error(.init(message: expectedMessage))) @@ -68,7 +77,7 @@ final class ChargeViewModelTests: PSTestCase { paymentChannels: [], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) XCTAssertTrue(serviceUnderTest.inTestMode) } @@ -78,7 +87,7 @@ final class ChargeViewModelTests: PSTestCase { paymentChannels: [], domain: .live, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference") + reference: "test_reference", channelOptions: .example) XCTAssertFalse(serviceUnderTest.inTestMode) } diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift new file mode 100644 index 0000000..da143d5 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift @@ -0,0 +1,245 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class MPesaChrageViewModelTests: XCTestCase { + + var serviceUnderTest: MPesaChrageViewModel! + var mockChargeContainer: MockChargeContainer! + var mockRepository: MockChargeMobileMoneyRepository! + + override func setUpWithError() throws { + try super.setUpWithError() + mockChargeContainer = MockChargeContainer() + mockRepository = MockChargeMobileMoneyRepository() + serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: .example, + repository: mockRepository) + } + + // MARK: - Phone number validation + + func testIsValidReturnsFalseWhenPhoneNumberIsLessThanTenDigits() { + serviceUnderTest.phoneNumber = "012345678" + XCTAssertFalse(serviceUnderTest.isValid) + } + + func testIsValidReturnsTrueWhenPhoneNumberIsAtLeastTenDigits() { + serviceUnderTest.phoneNumber = "0123456789" + XCTAssertTrue(serviceUnderTest.isValid) + } + + // MARK: - Initial state + + func testInitialStateIsCountdown() { + XCTAssertEqual(serviceUnderTest.transactionState, .countdown) + } + + // MARK: - submitPhoneNumber + + func testSubmitPhoneNumberForwardsPhoneTransactionIdAndProviderToRepository() async { + let expectedTransactionId = 987 + 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: expectedTransactionId, + channelOptions: .example) + serviceUnderTest = MPesaChrageViewModel(chargeCardContainer: mockChargeContainer, + transactionDetails: transactionDetails, + repository: mockRepository) + mockRepository.expectedMobileMoneyTransaction = .mPesaExample + + serviceUnderTest.phoneNumber = "0703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.phone, "+254703362111") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.transactionId, "\(expectedTransactionId)") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "MPESA") + } + + func testSubmitPhoneNumberForwardsFormattedPhoneWhenStartsWith254() async { + mockRepository.expectedMobileMoneyTransaction = .mPesaExample + + serviceUnderTest.phoneNumber = "254703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.phone, "+254703362111") + } + + func testSubmitPhoneNumberForwardsPhoneUnchangedWhenAlreadyStartsWithPlus254() async { + mockRepository.expectedMobileMoneyTransaction = .mPesaExample + + serviceUnderTest.phoneNumber = "+254703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.phone, "+254703362111") + } + + func testSubmitPhoneNumberWithMissingTransactionIdDefaultsToZero() async { + mockRepository.expectedMobileMoneyTransaction = .mPesaExample + + serviceUnderTest.phoneNumber = "0703362111" + await serviceUnderTest.submitPhoneNumber() + + 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) + mockRepository.expectedMobileMoneyTransaction = .mPesaExample + + serviceUnderTest.phoneNumber = "0703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "") + } + + func testSubmitPhoneNumberOnSuccessSetsStateToProcessTransaction() async { + let expectedTransaction = MobileMoneyTransaction.mPesaExample + mockRepository.expectedMobileMoneyTransaction = expectedTransaction + + serviceUnderTest.phoneNumber = "0703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(serviceUnderTest.transactionState, + .processTransaction(transaction: expectedTransaction)) + } + + func testSubmitPhoneNumberOnErrorSetsStateToErrorWithMessage() async { + let expectedErrorMessage = "Network failed" + let expectedError: PaystackError = .response(code: 400, message: expectedErrorMessage) + mockRepository.expectedErrorResponse = expectedError + + serviceUnderTest.phoneNumber = "0703362111" + await serviceUnderTest.submitPhoneNumber() + + XCTAssertEqual(serviceUnderTest.transactionState, + .error(ChargeError(message: expectedErrorMessage))) + } + + // MARK: - processTransactionResponse + + func testProcessResponseWithSuccessCallsContainerProcessSuccessfulTransaction() async { + let response = ChargeCardTransaction(status: .success) + await serviceUnderTest.processTransactionResponse(response) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testProcessResponseWithFailedUsesMessageWhenProvided() async { + let response = ChargeCardTransaction(status: .failed, message: "Insufficient funds") + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .error(ChargeError(message: "Insufficient funds"))) + } + + func testProcessResponseWithFailedUsesDisplayTextWhenMessageMissing() async { + let response = ChargeCardTransaction(status: .failed, displayText: "Declined by provider") + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .error(ChargeError(message: "Declined by provider"))) + } + + func testProcessResponseWithFailedAndNoMessageOrDisplayTextDefaultsToDeclined() async { + let response = ChargeCardTransaction(status: .failed) + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .error(ChargeError(message: "Declined"))) + } + + func testProcessResponseWithTimeoutUsesDisplayTextWhenProvided() async { + let response = ChargeCardTransaction(status: .timeout, displayText: "Timed out on provider side") + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .fatalError(error: ChargeError(message: "Timed out on provider side"))) + } + + func testProcessResponseWithTimeoutAndNoDisplayTextDefaultsToPaymentTimedOut() async { + let response = ChargeCardTransaction(status: .timeout) + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .fatalError(error: ChargeError(message: "Payment timed out"))) + } + + func testProcessResponseWithPendingDoesNotChangeState() async { + serviceUnderTest.transactionState = .countdown + let response = ChargeCardTransaction(status: .pending) + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, .countdown) + } + + func testProcessResponseWithUnexpectedStatusSetsStateToFatalError() async { + let response = ChargeCardTransaction(status: .sendPin) + await serviceUnderTest.processTransactionResponse(response) + XCTAssertEqual(serviceUnderTest.transactionState, + .fatalError(error: .generic)) + } + + // MARK: - displayTransactionError, restart, cancel + + func testDisplayTransactionErrorSetsStateToErrorWithTheGivenError() async { + let error = ChargeError(message: "Something broke") + await serviceUnderTest.displayTransactionError(error) + XCTAssertEqual(serviceUnderTest.transactionState, .error(error)) + } + + func testRestartMPesaPaymentResetsStateToCountdown() { + serviceUnderTest.transactionState = .error(.generic) + serviceUnderTest.restartMPesaPayment() + XCTAssertEqual(serviceUnderTest.transactionState, .countdown) + } + + func testCancelTransactionRestartsPayment() { + serviceUnderTest.transactionState = .error(.generic) + serviceUnderTest.cancelTransaction() + XCTAssertEqual(serviceUnderTest.transactionState, .countdown) + } +} + +// MARK: - Equatable conformance for state assertions + +extension ChargeMobileMoneyState: Equatable { + public static func == (lhs: ChargeMobileMoneyState, rhs: ChargeMobileMoneyState) -> Bool { + switch (lhs, rhs) { + case (.loading(let first), .loading(let second)): + return first == second + case (.countdown, .countdown): + return true + case (.error(let first), .error(let second)): + return first == second + case (.fatalError(let first), .fatalError(let second)): + return first == second + case (.processTransaction(let first), .processTransaction(let second)): + return first == second + default: + return false + } + } +} + +private extension MobileMoneyTransaction { + static var mPesaExample: MobileMoneyTransaction { + MobileMoneyTransaction(transaction: "1504248187", + phone: "0703362111", + provider: "MPESA", + channelName: "MOBILE_MONEY_1504248187", + timer: 60, + message: "Authorize on your device") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift new file mode 100644 index 0000000..c28a26d --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift @@ -0,0 +1,118 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class MPesaProcessingViewModelTests: XCTestCase { + + var serviceUnderTest: MPesaProcessingViewModel! + var mockContainer: MockMPesaContainer! + var mockRepository: MockChargeMobileMoneyRepository! + var mobileMoneyTransaction: MobileMoneyTransaction! + + override func setUpWithError() throws { + try super.setUpWithError() + mockContainer = MockMPesaContainer() + mockRepository = MockChargeMobileMoneyRepository() + mobileMoneyTransaction = .mPesaExample + serviceUnderTest = MPesaProcessingViewModel(container: mockContainer, + mobileMoneyTransaction: mobileMoneyTransaction, + repository: mockRepository) + } + + // MARK: - initializeMPesaAuthorization + + func testInitializeMPesaAuthorizationForwardsTransactionIdToRepository() async { + mockRepository.expectedChargeCardTransaction = .example + await serviceUnderTest.initializeMPesaAuthorization() + XCTAssertEqual(mockRepository.listenForMPesaTransactionId, 1504248187) + } + + func testInitializeMPesaAuthorizationWithNonNumericTransactionDefaultsToZero() 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) + mockRepository.expectedChargeCardTransaction = .example + + await serviceUnderTest.initializeMPesaAuthorization() + + XCTAssertEqual(mockRepository.listenForMPesaTransactionId, 0) + } + + func testInitializeMPesaAuthorizationOnSuccessForwardsResponseToContainer() async { + let expectedResponse = ChargeCardTransaction(status: .success) + mockRepository.expectedChargeCardTransaction = expectedResponse + + await serviceUnderTest.initializeMPesaAuthorization() + + XCTAssertEqual(mockContainer.transactionResponse, expectedResponse) + } + + func testInitializeMPesaAuthorizationOnErrorForwardsErrorToContainer() async { + let expectedErrorMessage = "Subscription failed" + mockRepository.expectedErrorResponse = PaystackError.response(code: 500, message: expectedErrorMessage) + + await serviceUnderTest.initializeMPesaAuthorization() + + XCTAssertEqual(mockContainer.transactionError, ChargeError(message: expectedErrorMessage)) + } + + // MARK: - checkTransactionStatus (fire-and-forget Task wrapper) + + func testCheckTransactionStatusCallsRepositoryWithContainerAccessCodeAndForwardsResponse() async { + let accessCode = mockContainer.transactionDetails.accessCode + let expectedResponse = ChargeCardTransaction(status: .success) + mockRepository.expectedChargeCardTransaction = expectedResponse + + let expectation = expectation(description: "container receives processed response") + mockContainer.onProcessTransactionResponse = { expectation.fulfill() } + + serviceUnderTest.checkTransactionStatus() + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertEqual(mockRepository.pendingChargeAccessCode, accessCode) + XCTAssertEqual(mockContainer.transactionResponse, expectedResponse) + } + + func testCheckTransactionStatusOnErrorForwardsErrorToContainer() async { + let expectedErrorMessage = "Pending charge check failed" + mockRepository.expectedErrorResponse = PaystackError.response(code: 500, message: expectedErrorMessage) + + let expectation = expectation(description: "container receives error") + mockContainer.onDisplayTransactionError = { expectation.fulfill() } + + serviceUnderTest.checkTransactionStatus() + await fulfillment(of: [expectation], timeout: 1.0) + + XCTAssertEqual(mockContainer.transactionError, ChargeError(message: expectedErrorMessage)) + } + + // MARK: - cancelTransaction + + func testCancelTransactionAsksContainerToRestart() { + serviceUnderTest.cancelTransaction() + XCTAssertTrue(mockContainer.mPesaPaymentRestarted) + } + + // MARK: - transactionDetails + + func testTransactionDetailsComesFromContainer() { + XCTAssertEqual(serviceUnderTest.transactionDetails, mockContainer.transactionDetails) + } +} + +private extension MobileMoneyTransaction { + static var mPesaExample: MobileMoneyTransaction { + MobileMoneyTransaction(transaction: "1504248187", + phone: "0703362111", + provider: "MPESA", + channelName: "MOBILE_MONEY_1504248187", + timer: 60, + message: "Authorize on your device") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift new file mode 100644 index 0000000..a8bcae7 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class ChargeMobileMoneyRepositoryImplementationTests: PSTestCase { + + let apiKey = "testsk_Example" + var serviceUnderTest: ChargeMobileMoneyRepositoryImplementation! + var paystack: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + paystack = try PaystackBuilder.newInstance.setKey(apiKey).build() + PaystackContainer.instance.store(paystack) + serviceUnderTest = ChargeMobileMoneyRepositoryImplementation() + } + + func testChargeMobileMoneySubmitsRequestUsingPaystackObjectAndMapsCorrectlyToModel() async throws { + let transactionId = "1504248187" + + mockServiceExecutor + .expectURL("https://api.paystack.co/charge/mobile_money") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeMobileMoneyResponse") + + let result = try await serviceUnderTest.chargeMobileMoney(phone: "0703362111", + transactionId: transactionId, + provider: "MPESA") + XCTAssertEqual(result, .jsonExample) + } + + func testListenForMPesaSubscribesToMobileMoneyChannelAndMapsSuccessToSuccess() async throws { + let transactionId = 1234 + let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", + eventName: "response") + // swiftlint:disable:next line_length + let responseString = "{\"redirecturl\":\"?trxref=2wdckavunc&reference=2wdckavunc\",\"trans\":\"1234\",\"trxref\":\"2wdckavunc\",\"reference\":\"2wdckavunc\",\"status\":\"success\",\"message\":\"Success\",\"response\":\"Approved\"}" + + mockSubscriptionListener + .expectSubscription(mockSubscription) + .andReturnString(responseString) + + let result = try await serviceUnderTest.listenForMPesa(for: transactionId) + XCTAssertEqual(result, .init(status: .success)) + } + + func testListenForMPesaMapsFailedSubscriptionResponseToFailedStatus() async throws { + let transactionId = 4321 + let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", + eventName: "response") + // swiftlint:disable:next line_length + let responseString = "{\"redirecturl\":\"?trxref=ref&reference=ref\",\"trans\":\"4321\",\"trxref\":\"ref\",\"reference\":\"ref\",\"status\":\"failed\",\"message\":\"Declined\",\"response\":\"Declined\"}" + + mockSubscriptionListener + .expectSubscription(mockSubscription) + .andReturnString(responseString) + + let result = try await serviceUnderTest.listenForMPesa(for: transactionId) + XCTAssertEqual(result, .init(status: .failed)) + } + + func testCheckPendingChargeSubmitsRequestUsingPaystackObjectAndMapsCorrectlyToModel() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/charge/access_code_test") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + let result = try await serviceUnderTest.checkPendingCharge(with: "access_code_test") + XCTAssertEqual(result, .jsonExample) + } +} + +// MARK: - Expected response models + +private extension MobileMoneyTransaction { + static var jsonExample: MobileMoneyTransaction { + MobileMoneyTransaction(transaction: "1504248187", + phone: "0703362111", + provider: "MPESA", + channelName: "MOBILE_MONEY_1504248187", + timer: 60, + message: "Please complete authorization process on your mobile phone") + } +} + +private extension ChargeCardTransaction { + static var jsonExample: ChargeCardTransaction { + ChargeCardTransaction(status: .success, + message: "madePayment", + countryCode: "NG") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift new file mode 100644 index 0000000..f7e9054 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift @@ -0,0 +1,39 @@ +import Foundation +@testable import PaystackUI + +class MockChargeMobileMoneyRepository: ChargeMobileMoneyRepository { + + var expectedMobileMoneyTransaction: MobileMoneyTransaction? + var expectedChargeCardTransaction: ChargeCardTransaction? + var expectedErrorResponse: Error? + + var chargeMobileMoneySubmitted: (phone: String, transactionId: String, + provider: String) = ("", "", "") + var listenForMPesaTransactionId: Int? + var pendingChargeAccessCode: String? + + func chargeMobileMoney(phone: String, transactionId: String, + provider: String) async throws -> MobileMoneyTransaction { + chargeMobileMoneySubmitted = (phone, transactionId, provider) + guard let response = expectedMobileMoneyTransaction else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return response + } + + func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction { + listenForMPesaTransactionId = transactionId + guard let response = expectedChargeCardTransaction else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return response + } + + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction { + pendingChargeAccessCode = accessCode + guard let response = expectedChargeCardTransaction else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return response + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift new file mode 100644 index 0000000..1abdb58 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import PaystackUI + +class MockMPesaContainer: MPesaContainer { + + var transactionDetails: VerifyAccessCode = .example + + var mPesaPaymentRestarted = false + var transactionResponse: ChargeCardTransaction? + var transactionError: ChargeError? + + var onProcessTransactionResponse: (() -> Void)? + var onDisplayTransactionError: (() -> Void)? + + func processTransactionResponse(_ response: ChargeCardTransaction) async { + transactionResponse = response + onProcessTransactionResponse?() + } + + func displayTransactionError(_ error: ChargeError) { + transactionError = error + onDisplayTransactionError?() + } + + func restartMPesaPayment() { + mPesaPaymentRestarted = true + } +} diff --git a/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift b/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift new file mode 100644 index 0000000..607392b --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import PaystackUI + +final class StringExtensionsTests: XCTestCase { + + // MARK: - formattedKenyanPhoneNumber + + func testFormattedKenyanPhoneNumberWithLeadingZeroReplacesZeroWithCountryCode() { + let phone = "0703362111" + XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + } + + func testFormattedKenyanPhoneNumberWithLeading254PrependsPlus() { + let phone = "254703362111" + XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + } + + func testFormattedKenyanPhoneNumberWithLeadingPlus254ReturnsSameNumber() { + let phone = "+254703362111" + XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + } + + func testFormattedKenyanPhoneNumberStripsWhitespaceBeforeFormatting() { + let phone = " 0703 362 111 " + XCTAssertEqual(phone.formattedKenyanPhoneNumber, "+254703362111") + } + + func testFormattedKenyanPhoneNumberWithUnrecognisedPrefixReturnsInputWithoutWhitespace() { + let phone = "7033 62111" + XCTAssertEqual(phone.formattedKenyanPhoneNumber, "703362111") + } +}