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")
+ }
+}