From dbc83e06fd75b1fd63dbaecdf0d05d2851aec11f Mon Sep 17 00:00:00 2001 From: Peter-John Date: Fri, 24 Nov 2023 11:57:17 +0200 Subject: [PATCH 01/14] MOB-875 Adding MobileMoney Charge endpoint - Added Mobile Money as a supported channel - Added endpoint for Mobile Money charge - Added listener for mobile money events - Added unit tests for endpoint and listener for mobile money charge - Added Mock responses - Updated tests to support mobile money as a channel --- Example/paystack-sdk-ios/ContentView.swift | 14 +++++++-- Package.swift | 4 ++- Sources/PaystackSDK/API/Charge/Charge.swift | 22 +++++++++++++ .../API/Charge/MobileMoneyService.swift | 19 ++++++++++++ .../PaystackSDK/Core/Models/MobileMoney.swift | 9 ++++++ .../Core/Models/Models/Channel.swift | 1 + .../Models/MobileMoneyChargeRequest.swift | 11 +++++++ .../Models/MobileMoneyChargeResponse.swift | 26 ++++++++++++++++ .../Core/Models/Models/MobileMoneyData.swift | 19 ++++++++++++ .../VerifyAccessCode/ChannelOptions.swift | 5 ++- .../API/Charge/ChargeTests.swift | 31 +++++++++++++++++++ .../Resources/ChargeMobileMoneyResponse.json | 15 +++++++++ .../Resources/VerifyAccessCode.json | 17 +++++++++- .../ChargeRepositoryImplementationTests.swift | 2 +- 14 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 Sources/PaystackSDK/API/Charge/MobileMoneyService.swift create mode 100644 Sources/PaystackSDK/Core/Models/MobileMoney.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json diff --git a/Example/paystack-sdk-ios/ContentView.swift b/Example/paystack-sdk-ios/ContentView.swift index e536dbf..dd2760f 100644 --- a/Example/paystack-sdk-ios/ContentView.swift +++ b/Example/paystack-sdk-ios/ContentView.swift @@ -20,8 +20,18 @@ struct ContentView: View { } .padding() } - - func paymentDone(_ result: TransactionResult) {} + + func paymentDone(_ result: TransactionResult) { + + switch result { + case .completed(let chargeDetails): + print("Success: Transaction reference : \(chargeDetails.reference)") + case .cancelled : + print("Transaction was cancelled.") + case .error(error: let error, reference: let reference) : + print("An error occured with \(reference!) : \(error.message)") + } + } } struct ContentView_Previews: PreviewProvider { diff --git a/Package.swift b/Package.swift index 5fbac13..b8ee040 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,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..2f69cd0 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -4,6 +4,10 @@ public extension Paystack { private var service: ChargeService { return ChargeServiceImplementation(config: config) } + + private var mobileMoneyService: MobileMoneyService { + return MobileMoneyServiceImplementation(config: config) + } /// Continues the Charge flow by authenticating a user with an OTP /// - Parameters: @@ -72,5 +76,23 @@ public extension Paystack { let subscription: any Subscription = PusherSubscription(channelName: channelName, eventName: "response") 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: mobileMoneyData.channelName, amount: mobileMoneyData.amount, email: mobileMoneyData.email, phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) + return mobileMoneyService.postChargeMobileMoney(request) + } } diff --git a/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift new file mode 100644 index 0000000..2f4c3e3 --- /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..4ad8f90 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/MobileMoney.swift @@ -0,0 +1,9 @@ +import Foundation + +// MARK: - MobileMoney +public struct MobileMoney: Codable { + let key : String + let value: String + let isNew: Bool + let phoneNumberRegex: String +} 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/MobileMoneyChargeRequest.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift new file mode 100644 index 0000000..aee2b17 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +// MARK: - MobileMoneyChargeRequest +struct MobileMoneyChargeRequest: Codable { + let channelName: String + let amount: Int + let email: 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..8f813d6 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -0,0 +1,26 @@ +import Foundation + +// MARK: - MobileMoneyChargeResponse +public struct MobileMoneyChargeResponse: Codable { + let status: Bool + let message: String + let data: MobileMoneyChargeData +} + +// MARK: - MobileMoneyChargeData +public struct MobileMoneyChargeData: Codable { + let transaction, phone, provider, channelName: String + let display: Display + + enum CodingKeys: String, CodingKey { + case transaction, phone, provider + case channelName = "channel_name" + case display + } +} + +// MARK: - Display +public struct Display: Codable { + let type, message: String + 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..573990b --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct MobileMoneyData: Equatable { + let channelName: String + let amount: Int + let email : String + let phone: String + let transaction: String + let provider: String + + public init(channelName: String, amount: Int, email: String, phone: String, transaction: String, provider: String) { + self.channelName = channelName + self.amount = amount + self.email = email + 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..d4cfdda 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 = "mobile_money" } } diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index 826ebbe..a790092 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -26,6 +26,22 @@ final class ChargeTests: PSTestCase { _ = try serviceUnderTest.authenticateCharge(withOtp: "12345", accessCode: "abcde").sync() } + + func testMobileMoneyCharge() throws { + let mobileMoneyRequestBody = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_1504248187", amount: 1000, email: "peter@paystack.com", phone: "0723362418", transaction: "1504248187", provider: "MPESA") + + mockServiceExecutor + .expectURL("https://api.paystack.co/charge/mobile_money") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .expectBody(mobileMoneyRequestBody) + .andReturn(json: "ChargeMobileMoneyResponse") + + let mobileMoneyData = MobileMoneyData(channelName: "MOBILE_MONEY_1504248187", amount: 1000, email: "peter@paystack.com", phone: "0723362418", transaction: "1504248187", provider: "MPESA") + + _ = try serviceUnderTest.chargeMobileMoney(with: mobileMoneyData).sync() + } + func testAuthenticateChargeWithPhoneAuthentication() throws { let phoneRequestBody = SubmitPhoneRequest(phone: "0111234567", accessCode: "abcde") @@ -89,5 +105,20 @@ final class ChargeTests: PSTestCase { _ = 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() + } } 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/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 2e4ca1e..f7a9328 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -26,7 +26,7 @@ final class ChargeRepositoryImplementationTests: PSTestCase { 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", From 49156d41b47e78b8c2df2f1215de889d390b7903 Mon Sep 17 00:00:00 2001 From: Peter-John Date: Fri, 24 Nov 2023 12:29:21 +0200 Subject: [PATCH 02/14] MOB-875 Adding MobileMoney Charge endpoint - Added Mobile Money as a supported channel - Added endpoint for Mobile Money charge - Added listener for mobile money events - Added unit tests for endpoint and listener for mobile money charge - Added Mock responses - Updated tests to support mobile money as a channel --- .../Core/Models/Models/MobileMoneyChargeResponse.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift index 8f813d6..a7dcb56 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -14,7 +14,7 @@ public struct MobileMoneyChargeData: Codable { enum CodingKeys: String, CodingKey { case transaction, phone, provider - case channelName = "channel_name" + case channelName case display } } From 570ca660df623fbac12428eb90e53db8fd7897e7 Mon Sep 17 00:00:00 2001 From: Peter-John Date: Fri, 24 Nov 2023 12:53:55 +0200 Subject: [PATCH 03/14] MOB-875 Adding MobileMoney Charge endpoint - Added Mobile Money as a supported channel - Added endpoint for Mobile Money charge - Added listener for mobile money events - Added unit tests for endpoint and listener for mobile money charge - Added Mock responses - Updated tests to support mobile money as a channel --- Example/paystack-sdk-ios/ContentView.swift | 8 ++++---- Package.swift | 2 +- Sources/PaystackSDK/API/Charge/Charge.swift | 7 +++---- .../PaystackSDK/API/Charge/MobileMoneyService.swift | 6 +++--- Sources/PaystackSDK/Core/Models/MobileMoney.swift | 2 +- .../Models/Models/MobileMoneyChargeResponse.swift | 12 +++++++++--- .../Core/Models/Models/MobileMoneyData.swift | 4 ++-- Tests/PaystackSDKTests/API/Charge/ChargeTests.swift | 7 +++---- .../Charge/ChargeRepositoryImplementationTests.swift | 2 +- 9 files changed, 27 insertions(+), 23 deletions(-) diff --git a/Example/paystack-sdk-ios/ContentView.swift b/Example/paystack-sdk-ios/ContentView.swift index dd2760f..fcf727d 100644 --- a/Example/paystack-sdk-ios/ContentView.swift +++ b/Example/paystack-sdk-ios/ContentView.swift @@ -20,15 +20,15 @@ struct ContentView: View { } .padding() } - + func paymentDone(_ result: TransactionResult) { - + switch result { case .completed(let chargeDetails): print("Success: Transaction reference : \(chargeDetails.reference)") - case .cancelled : + case .cancelled: print("Transaction was cancelled.") - case .error(error: let error, reference: let reference) : + case .error(error: let error, reference: let reference): print("An error occured with \(reference!) : \(error.message)") } } diff --git a/Package.swift b/Package.swift index b8ee040..7a921fc 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,7 @@ let package = Package( .copy("API/Charge/Resources/ChargeAuthenticationResponse.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 2f69cd0..3c9184c 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -4,7 +4,7 @@ public extension Paystack { private var service: ChargeService { return ChargeServiceImplementation(config: config) } - + private var mobileMoneyService: MobileMoneyService { return MobileMoneyServiceImplementation(config: config) } @@ -76,7 +76,7 @@ public extension Paystack { let subscription: any Subscription = PusherSubscription(channelName: channelName, eventName: "response") 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 @@ -85,7 +85,7 @@ public extension Paystack { 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 @@ -94,5 +94,4 @@ public extension Paystack { let request = MobileMoneyChargeRequest(channelName: mobileMoneyData.channelName, amount: mobileMoneyData.amount, email: mobileMoneyData.email, phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) return mobileMoneyService.postChargeMobileMoney(request) } - } diff --git a/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift index 2f4c3e3..e2fc647 100644 --- a/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift +++ b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift @@ -4,14 +4,14 @@ protocol MobileMoneyService: PaystackService { func postChargeMobileMoney(_ request: MobileMoneyChargeRequest) -> Service } -struct MobileMoneyServiceImplementation : MobileMoneyService { - +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 index 4ad8f90..ce88752 100644 --- a/Sources/PaystackSDK/Core/Models/MobileMoney.swift +++ b/Sources/PaystackSDK/Core/Models/MobileMoney.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - MobileMoney public struct MobileMoney: Codable { - let key : String + let key: String let value: String let isNew: Bool let phoneNumberRegex: String diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift index a7dcb56..cb8575a 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -9,11 +9,16 @@ public struct MobileMoneyChargeResponse: Codable { // MARK: - MobileMoneyChargeData public struct MobileMoneyChargeData: Codable { - let transaction, phone, provider, channelName: String + let transaction: String + let phone: String + let provider: String + let channelName: String let display: Display enum CodingKeys: String, CodingKey { - case transaction, phone, provider + case transaction + case phone + case provider case channelName case display } @@ -21,6 +26,7 @@ public struct MobileMoneyChargeData: Codable { // MARK: - Display public struct Display: Codable { - let type, message: String + let type: String + let message: String let timer: Int } diff --git a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift index 573990b..a6075b7 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift @@ -3,11 +3,11 @@ import Foundation public struct MobileMoneyData: Equatable { let channelName: String let amount: Int - let email : String + let email: String let phone: String let transaction: String let provider: String - + public init(channelName: String, amount: Int, email: String, phone: String, transaction: String, provider: String) { self.channelName = channelName self.amount = amount diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index a790092..ee798fa 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -26,7 +26,7 @@ final class ChargeTests: PSTestCase { _ = try serviceUnderTest.authenticateCharge(withOtp: "12345", accessCode: "abcde").sync() } - + func testMobileMoneyCharge() throws { let mobileMoneyRequestBody = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_1504248187", amount: 1000, email: "peter@paystack.com", phone: "0723362418", transaction: "1504248187", provider: "MPESA") @@ -36,13 +36,12 @@ final class ChargeTests: PSTestCase { .expectHeader("Authorization", "Bearer \(apiKey)") .expectBody(mobileMoneyRequestBody) .andReturn(json: "ChargeMobileMoneyResponse") - + let mobileMoneyData = MobileMoneyData(channelName: "MOBILE_MONEY_1504248187", amount: 1000, email: "peter@paystack.com", phone: "0723362418", transaction: "1504248187", provider: "MPESA") _ = try serviceUnderTest.chargeMobileMoney(with: mobileMoneyData).sync() } - func testAuthenticateChargeWithPhoneAuthentication() throws { let phoneRequestBody = SubmitPhoneRequest(phone: "0111234567", accessCode: "abcde") @@ -105,7 +104,7 @@ final class ChargeTests: PSTestCase { _ = try serviceUnderTest.listenFor3DSResponse(for: transactionId).sync() } - + func testListenForMobileMoney() throws { let transactionId = 1234 let mockSubscription = PusherSubscription(channelName: "MOBILE_MONEY_\(transactionId)", diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index f7a9328..5b598ad 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -26,7 +26,7 @@ final class ChargeRepositoryImplementationTests: PSTestCase { let expectedResult = VerifyAccessCode(amount: 10000, currency: "NGN", accessCode: "Access_Code_Test", - paymentChannels: [.card, .qr, .ussd,.mobileMoney], + paymentChannels: [.card, .qr, .ussd, .mobileMoney], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", From cb4ab6b62611e48519ca4d378b0416a6ea1d366e Mon Sep 17 00:00:00 2001 From: Peter-John Date: Fri, 24 Nov 2023 14:14:34 +0200 Subject: [PATCH 04/14] MOB-875 Adding MobileMoney Charge endpoint - Added Mobile Money as a supported channel - Added endpoint for Mobile Money charge - Added listener for mobile money events - Added unit tests for endpoint and listener for mobile money charge - Added Mock responses - Updated tests to support mobile money as a channel --- Sources/PaystackSDK/API/Charge/Charge.swift | 2 ++ Tests/PaystackSDKTests/API/Charge/ChargeTests.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/PaystackSDK/API/Charge/Charge.swift b/Sources/PaystackSDK/API/Charge/Charge.swift index 3c9184c..d4990bc 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 import Foundation public extension Paystack { @@ -95,3 +96,4 @@ public extension Paystack { return mobileMoneyService.postChargeMobileMoney(request) } } +// swiftlint:enable file_length type_body_length diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index ee798fa..0401be3 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length type_body_length import XCTest @testable import PaystackCore @@ -121,3 +122,4 @@ final class ChargeTests: PSTestCase { } } +// swiftlint:enable file_length type_body_length From 9054f47ecd534bfe4bd1bf23041a36c78b2f45c8 Mon Sep 17 00:00:00 2001 From: Peter-John Date: Mon, 27 Nov 2023 15:12:36 +0200 Subject: [PATCH 05/14] MOB-875 Adding MobileMoney Charge endpoint - Added Mobile Money as a supported channel - Added endpoint for Mobile Money charge - Added listener for mobile money events - Added unit tests for endpoint and listener for mobile money charge - Added Mock responses - Updated tests to support mobile money as a channel --- Sources/PaystackSDK/API/Charge/Charge.swift | 4 ++-- Tests/PaystackSDKTests/API/Charge/ChargeTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/PaystackSDK/API/Charge/Charge.swift b/Sources/PaystackSDK/API/Charge/Charge.swift index d4990bc..230dc5c 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -1,4 +1,4 @@ -// swiftlint:disable file_length type_body_length +// swiftlint:disable file_length type_body_length line_length import Foundation public extension Paystack { @@ -96,4 +96,4 @@ public extension Paystack { return mobileMoneyService.postChargeMobileMoney(request) } } -// swiftlint:enable file_length type_body_length +// swiftlint:enable file_length type_body_length line_length diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index 0401be3..067a8a2 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -1,4 +1,4 @@ -// swiftlint:disable file_length type_body_length +// swiftlint:disable file_length type_body_length line_length import XCTest @testable import PaystackCore @@ -122,4 +122,4 @@ final class ChargeTests: PSTestCase { } } -// swiftlint:enable file_length type_body_length +// swiftlint:enable file_length type_body_length line_length From 79b92de23d6573d4643f572ec8c94f007748ef0f Mon Sep 17 00:00:00 2001 From: Peter-John Date: Mon, 19 Feb 2024 15:37:35 +0200 Subject: [PATCH 06/14] Mpesa --- .github/workflows/build.yml | 2 +- .github/workflows/deploy.yml | 59 ++++++++ .github/workflows/release.yml | 3 +- .../xcschemes/PaystackCore.xcscheme | 66 +++++++++ .../xcschemes/PaystackSDK-Package.xcscheme | 106 ++++++++++++++ .../xcschemes/PaystackUI.xcscheme | 66 +++++++++ Example/Podfile.lock | 2 +- .../project.pbxproj | 19 +++ PaystackCore.podspec | 2 +- PaystackUI.podspec | 2 +- Sources/PaystackSDK/API/Charge/Charge.swift | 2 +- .../PaystackSDK/Core/Models/CardType.swift | 4 +- .../Core/Models/Custom/IntegerObject.swift | 2 +- .../Models/Custom/OptionalStringObject.swift | 4 +- .../PaystackSDK/Core/Models/CustomField.swift | 2 +- .../PaystackSDK/Core/Models/Metadata.swift | 8 +- .../PaystackSDK/Core/Models/MobileMoney.swift | 15 +- .../Charge/Charge3DSResponseStatus.swift | 1 + .../Models/MobileMoneyChargeRequest.swift | 4 +- .../Models/MobileMoneyChargeResponse.swift | 22 +-- .../Core/Models/Models/MobileMoneyData.swift | 8 +- .../Models/Models/ModelErrorResponse.swift | 3 - .../VerifyAccessCode/ChannelOptions.swift | 2 +- Sources/PaystackSDK/Core/Paystack.swift | 6 +- .../Core/Service/Extensions/Publisher.swift | 6 +- .../Core/Service/Extensions/Sync.swift | 8 +- .../Core/Service/PaystackService.swift | 11 +- .../Core/Service/ServiceExecutor.swift | 4 +- .../Service/ServiceExecutorProvider.swift | 10 +- .../PusherSubscriptionListener.swift | 8 +- .../Extensions/PaystackUserAgent.swift | 6 +- .../URLRequest/URLRequestBuilder.swift | 48 +++---- .../Core/Utils/Cryptography/Asn1Parser.swift | 66 ++++----- .../Core/Utils/DateFormatter.swift | 2 +- .../PaystackSDK/Core/Utils/JSONDecoder.swift | 4 +- .../PaystackSDK/Core/Utils/JSONEncoder.swift | 4 +- .../PaystackSDK/Core/Utils/Queryable.swift | 4 +- .../PaystackSDK/Versioning/update_version.sh | 5 +- Sources/PaystackSDK/Versioning/versions.plist | 4 +- Sources/PaystackUI/Charge/ChargeView.swift | 6 + .../PaystackUI/Charge/ChargeViewModel.swift | 44 +++++- .../Models/MobileMoneyChannel.swift | 22 +++ .../Models/MobileMoneyTransaction.swift | 19 +++ .../ChargeMobileMoneyRepository.swift | 33 +++++ .../Viewmodels/MPesaChrageViewModel.swift | 51 +++++++ .../Viewmodels/MPesaProcessingViewModel.swift | 63 ++++++++ .../Views/ChannelSelectionView.swift | 115 +++++++++++++++ .../MobileMoney/Views/MPesaChargeView.swift | 65 +++++++++ .../Views/MPesaProcessingView.swift | 136 ++++++++++++++++++ .../Charge/Models/ChannelOptions.swift | 21 +++ .../Charge/Models/ChargePaymentType.swift | 1 + .../Charge/Models/ChargeState.swift | 3 +- .../Charge/Models/VerifyAccessCode.swift | 5 +- .../Styles/PrimaryButtonStyle.swift | 63 ++++++++ Sources/PaystackUI/Images/Images.swift | 20 +++ .../cardLogo.imageset/Contents.json | 21 +++ .../cardLogo.imageset/Selected.png | Bin 0 -> 352 bytes .../kenyaFlagLogo.imageset/Contents.json | 21 +++ .../kenyaFlagLogo.imageset/Flags.png | Bin 0 -> 529 bytes .../kenyaSHLogo.imageset/Contents.json | 21 +++ .../kenyaSHLogo.imageset/Ksh.png | Bin 0 -> 410 bytes .../messageBubbleLogo.imageset/Contents.json | 21 +++ .../messageBubbleLogo.imageset/bubble.png | Bin 0 -> 360 bytes .../API/Charge/ChargeTests.swift | 4 +- .../Common/MockServiceExecutor.swift | 32 ++--- .../Core/PaystackBuilderTests.swift | 12 +- .../ChargeCard/ChargeCardViewModelTests.swift | 16 +-- .../ChargeRepositoryImplementationTests.swift | 2 +- .../UI/Charge/ChargeViewModelTests.swift | 6 +- 69 files changed, 1240 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/PaystackCore.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/PaystackSDK-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/PaystackUI.xcscheme create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift create mode 100644 Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift create mode 100644 Sources/PaystackUI/Charge/Models/ChannelOptions.swift create mode 100644 Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/cardLogo.imageset/Selected.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/kenyaFlagLogo.imageset/Flags.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/kenyaSHLogo.imageset/Ksh.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/messageBubbleLogo.imageset/bubble.png diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fe5436..53cf78c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,7 @@ jobs: # TODO: Add sonar here once the project is added release: - if: github.event.pull_request.merged == true && github.head_ref == 'release/update-versions' + if: ${{ github.event.pull_request.merged == true && github.head_ref == 'release/update-versions' }} runs-on: macos-12 needs: [build, PodLinting] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..92e0214 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deployment + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: macos-latest + name: Deploy to Cocoapods Trunk + + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - uses: actions/checkout@v3 + + - name: setup-cocoapods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + version: 1.11.3 + + - name: Run pod lint for Paystack Core + run: pod lib lint PaystackCore.podspec --allow-warnings + + - name: Get version information + run: | + version=$(/usr/libexec/PlistBuddy -c "Print Version" ./Sources/PaystackSDK/Versioning/versions.plist) + echo "version=${version}" >> $GITHUB_ENV + body=$(/usr/libexec/PlistBuddy -c "Print Description" ./Sources/PaystackSDK/Versioning/versions.plist) + echo "body=${body}" >> $GITHUB_ENV + cd Sources/PaystackSDK/Core/Service/Subscription + PLIST=secrets.plist + /usr/libexec/PlistBuddy -c "Set PUSHER_API_KEY $PUSH_TOKEN" $PLIST + cd ../../../../.. + env: + PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }} + + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.version }} + release_name: ${{ env.version }} + body: ${{ env.body }} + + - name: Install Cocoapods + run: gem install cocoapods + + - name: Deploy to Cocoapods + run: | + set -eo pipefail + pod trunk push PaystackCore.podspec --allow-warnings + pod trunk push PaystackUI.podspec --allow-warnings + env: + COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94e30ca..1ff8eb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,8 @@ jobs: - name: Increment Latest Version run: | mode=${{ github.event.inputs.release }} - sh Sources/PaystackSDK/Versioning/update_version.sh $mode ${{ github.event.inputs.body }} + cd Sources/PaystackSDK + sh Versioning/update_version.sh $mode ${{ github.event.inputs.body }} - name: Open Pull Request uses: peter-evans/create-pull-request@v4 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.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/PaystackCore.podspec b/PaystackCore.podspec index c9f2df1..3014086 100644 --- a/PaystackCore.podspec +++ b/PaystackCore.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'PaystackCore' - s.version = '0.1.0' + s.version = '0.0.1' s.summary = 'The Paystack Public iOS SDK' # TODO: Add correct descriptions diff --git a/PaystackUI.podspec b/PaystackUI.podspec index fb99880..e448bc3 100644 --- a/PaystackUI.podspec +++ b/PaystackUI.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'PaystackUI' - s.version = '0.1.0' + s.version = '0.0.1' s.summary = 'The UI Flows build upon the Paystack Public iOS SDK' # TODO: Add correct descriptions diff --git a/Sources/PaystackSDK/API/Charge/Charge.swift b/Sources/PaystackSDK/API/Charge/Charge.swift index 230dc5c..3a8dc33 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -92,7 +92,7 @@ public extension Paystack { /// - 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: mobileMoneyData.channelName, amount: mobileMoneyData.amount, email: mobileMoneyData.email, phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) + let request = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_\(mobileMoneyData.transaction)", phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) return mobileMoneyService.postChargeMobileMoney(request) } } diff --git a/Sources/PaystackSDK/Core/Models/CardType.swift b/Sources/PaystackSDK/Core/Models/CardType.swift index 4fa178c..0567f97 100644 --- a/Sources/PaystackSDK/Core/Models/CardType.swift +++ b/Sources/PaystackSDK/Core/Models/CardType.swift @@ -4,7 +4,7 @@ public enum CardType: String, Decodable { case visa case visaDebit = "visa DEBIT" case mastercardDebit = "mastercard DEBIT" - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) @@ -15,7 +15,7 @@ public enum CardType: String, Decodable { debugDescription: "Could not find value for \(trimmedString) in CardType", underlyingError: nil)) } - + self = value } } diff --git a/Sources/PaystackSDK/Core/Models/Custom/IntegerObject.swift b/Sources/PaystackSDK/Core/Models/Custom/IntegerObject.swift index a845212..24de91d 100644 --- a/Sources/PaystackSDK/Core/Models/Custom/IntegerObject.swift +++ b/Sources/PaystackSDK/Core/Models/Custom/IntegerObject.swift @@ -3,7 +3,7 @@ import Foundation /// Service sometimes returns a string for an integer field public struct IntegerObject: Decodable { public var value: Int - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() do { diff --git a/Sources/PaystackSDK/Core/Models/Custom/OptionalStringObject.swift b/Sources/PaystackSDK/Core/Models/Custom/OptionalStringObject.swift index b471b0d..4b6ad0b 100644 --- a/Sources/PaystackSDK/Core/Models/Custom/OptionalStringObject.swift +++ b/Sources/PaystackSDK/Core/Models/Custom/OptionalStringObject.swift @@ -6,9 +6,9 @@ public struct OptionalStringObject: Decodable { enum CodingKeys: String, CodingKey { case value } - + public var value: String? - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() self.value = try? container.decode(String.self) diff --git a/Sources/PaystackSDK/Core/Models/CustomField.swift b/Sources/PaystackSDK/Core/Models/CustomField.swift index 8f06d17..4fe723e 100644 --- a/Sources/PaystackSDK/Core/Models/CustomField.swift +++ b/Sources/PaystackSDK/Core/Models/CustomField.swift @@ -4,7 +4,7 @@ public struct CustomField: Codable { public var displayName: String public var variableName: String public var value: String - + public init(displayName: String, variableName: String, value: String) { diff --git a/Sources/PaystackSDK/Core/Models/Metadata.swift b/Sources/PaystackSDK/Core/Models/Metadata.swift index 1d9be8e..e3f625c 100644 --- a/Sources/PaystackSDK/Core/Models/Metadata.swift +++ b/Sources/PaystackSDK/Core/Models/Metadata.swift @@ -2,22 +2,22 @@ import Foundation public struct Metadata: Codable { public var customFields: [CustomField]? - + public init(customFields: [CustomField]?) { self.customFields = customFields } - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let string = try? container.decode(String.self) guard let data = string?.data(using: .utf8), data.count > 0 else { return } - + let metadata = try JSONDecoder.decoder.decode(InnerMetadata.self, from: data) self.customFields = metadata.customFields } - + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() let data = try JSONEncoder.encoder.encode(InnerMetadata(customFields: customFields)) diff --git a/Sources/PaystackSDK/Core/Models/MobileMoney.swift b/Sources/PaystackSDK/Core/Models/MobileMoney.swift index ce88752..e73d6e6 100644 --- a/Sources/PaystackSDK/Core/Models/MobileMoney.swift +++ b/Sources/PaystackSDK/Core/Models/MobileMoney.swift @@ -2,8 +2,15 @@ import Foundation // MARK: - MobileMoney public struct MobileMoney: Codable { - let key: String - let value: String - let isNew: Bool - let phoneNumberRegex: String + 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/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 index aee2b17..475f88f 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeRequest.swift @@ -1,10 +1,8 @@ import Foundation // MARK: - MobileMoneyChargeRequest -struct MobileMoneyChargeRequest: Codable { +public struct MobileMoneyChargeRequest: Codable { let channelName: String - let amount: Int - let email: 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 index cb8575a..4e7995c 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -2,18 +2,18 @@ import Foundation // MARK: - MobileMoneyChargeResponse public struct MobileMoneyChargeResponse: Codable { - let status: Bool - let message: String - let data: MobileMoneyChargeData + public let status: Bool + public let message: String + public let data: MobileMoneyChargeData } // MARK: - MobileMoneyChargeData public struct MobileMoneyChargeData: Codable { - let transaction: String - let phone: String - let provider: String - let channelName: String - let display: Display + 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 @@ -26,7 +26,7 @@ public struct MobileMoneyChargeData: Codable { // MARK: - Display public struct Display: Codable { - let type: String - let message: String - let timer: Int + 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 index a6075b7..e37a27c 100644 --- a/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyData.swift @@ -1,17 +1,11 @@ import Foundation public struct MobileMoneyData: Equatable { - let channelName: String - let amount: Int - let email: String let phone: String let transaction: String let provider: String - public init(channelName: String, amount: Int, email: String, phone: String, transaction: String, provider: String) { - self.channelName = channelName - self.amount = amount - self.email = email + 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/ModelErrorResponse.swift b/Sources/PaystackSDK/Core/Models/Models/ModelErrorResponse.swift index 632b44b..1fddfdc 100644 --- a/Sources/PaystackSDK/Core/Models/Models/ModelErrorResponse.swift +++ b/Sources/PaystackSDK/Core/Models/Models/ModelErrorResponse.swift @@ -7,8 +7,6 @@ import Foundation - - public struct ModelErrorResponse: Codable { public var status: Bool @@ -19,5 +17,4 @@ public struct ModelErrorResponse: Codable { self.message = message } - } diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift index d4cfdda..01f7735 100644 --- a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift @@ -18,6 +18,6 @@ public struct ChannelOptions: Codable { case ussd case qrCode = "qr" case bankTransfer = "bank_transfer" - case mobileMoney = "mobile_money" + case mobileMoney } } diff --git a/Sources/PaystackSDK/Core/Paystack.swift b/Sources/PaystackSDK/Core/Paystack.swift index f9a4e1c..69f695d 100644 --- a/Sources/PaystackSDK/Core/Paystack.swift +++ b/Sources/PaystackSDK/Core/Paystack.swift @@ -1,11 +1,11 @@ import Foundation public class Paystack { - + public let config: PaystackConfig - + init(config: PaystackConfig) { self.config = config } - + } diff --git a/Sources/PaystackSDK/Core/Service/Extensions/Publisher.swift b/Sources/PaystackSDK/Core/Service/Extensions/Publisher.swift index 030edea..e242a6e 100644 --- a/Sources/PaystackSDK/Core/Service/Extensions/Publisher.swift +++ b/Sources/PaystackSDK/Core/Service/Extensions/Publisher.swift @@ -3,18 +3,18 @@ import Combine @available(iOS 13.0, *) public extension Service { - + func publisher() -> AnyPublisher { return Future { promise in async { if let result = $0 { promise(.success(result)) } - + promise(.failure($1 ?? PaystackError.technical)) } } .eraseToAnyPublisher() } - + } diff --git a/Sources/PaystackSDK/Core/Service/Extensions/Sync.swift b/Sources/PaystackSDK/Core/Service/Extensions/Sync.swift index 4ab3824..9fb2535 100644 --- a/Sources/PaystackSDK/Core/Service/Extensions/Sync.swift +++ b/Sources/PaystackSDK/Core/Service/Extensions/Sync.swift @@ -1,7 +1,7 @@ import Foundation public extension Service { - + func sync() throws -> T { let semaphore = DispatchSemaphore(value: 0) var result: T? @@ -11,13 +11,13 @@ public extension Service { error = $1 semaphore.signal() } - + semaphore.wait() if let result = result { return result } - + throw error ?? PaystackError.technical } - + } diff --git a/Sources/PaystackSDK/Core/Service/PaystackService.swift b/Sources/PaystackSDK/Core/Service/PaystackService.swift index 898dac2..8188b2a 100644 --- a/Sources/PaystackSDK/Core/Service/PaystackService.swift +++ b/Sources/PaystackSDK/Core/Service/PaystackService.swift @@ -6,17 +6,18 @@ public protocol PaystackService: URLRequestBuilderHelper { } public extension PaystackService { - + var endpoint: String { - return "https://api.paystack.co/\(parentPath)" + return "https://api.paystack.co/\(parentPath)" + //return "https://studio-api.paystack.co/\(parentPath)" } - + var bearerToken: String { return config.apiKey } - + var paystackUserAgentVersion: String { return config.version } - + } diff --git a/Sources/PaystackSDK/Core/Service/ServiceExecutor.swift b/Sources/PaystackSDK/Core/Service/ServiceExecutor.swift index 2c7acae..c337d6e 100644 --- a/Sources/PaystackSDK/Core/Service/ServiceExecutor.swift +++ b/Sources/PaystackSDK/Core/Service/ServiceExecutor.swift @@ -5,9 +5,9 @@ protocol ServiceExecutor { } extension URLSession: ServiceExecutor { - + func execute(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { dataTask(with: request, completionHandler: completion).resume() } - + } diff --git a/Sources/PaystackSDK/Core/Service/ServiceExecutorProvider.swift b/Sources/PaystackSDK/Core/Service/ServiceExecutorProvider.swift index 8e9bcae..6bf5078 100644 --- a/Sources/PaystackSDK/Core/Service/ServiceExecutorProvider.swift +++ b/Sources/PaystackSDK/Core/Service/ServiceExecutorProvider.swift @@ -1,19 +1,19 @@ import Foundation class ServiceExecutorProvider { - + static var executor: ServiceExecutor = URLSession.shared - + static func execute(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { self.executor.execute(request: request, completion: completion) } - + static func set(executor: ServiceExecutor) { self.executor = executor } - + static func reset() { self.executor = URLSession.shared } - + } diff --git a/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift b/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift index df6c7c0..96b230c 100644 --- a/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift +++ b/Sources/PaystackSDK/Core/Service/Subscription/PusherSubscriptionListener.swift @@ -35,7 +35,7 @@ struct PusherSubscriptionListener: SubscriptionListener { let channel = pusher.subscribe(channelName: channelName) bindConnectionEvents(to: channel) pusher.connect() - + print(channelName) listenForData(on: channel, forEvent: eventName, subscriptionResponse: completion) } @@ -53,13 +53,17 @@ struct PusherSubscriptionListener: SubscriptionListener { on channel: PusherChannel, forEvent eventName: String, subscriptionResponse: @escaping (Result) -> Void ) { - + print(channel.subscriptionCount ?? 0) channel.bind(eventName: eventName, eventCallback: { guard let stringData = $0.data else { subscriptionResponse(.failure(.noData)) + pusher.disconnect() return } + print(channel.subscriptionCount ?? 0) subscriptionResponse(.success(stringData)) + print(channel.name) + pusher.unsubscribe(channel.name) }) } diff --git a/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift b/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift index 926fe67..23c0adc 100644 --- a/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift +++ b/Sources/PaystackSDK/Core/Service/URLRequest/Extensions/PaystackUserAgent.swift @@ -6,15 +6,15 @@ struct PaystackUserAgent: Encodable { } extension URLRequestBuilder { - + func addPaystackUserAgent(_ version: String) -> Self { let agent = PaystackUserAgent(bindingsVersion: version) guard let data = try? JSONEncoder.encoder.encode(agent), let agentString = String(data: data, encoding: .utf8) else { return self } - + return addHeader("X-Paystack-User-Agent", agentString) } - + } diff --git a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift index a32178b..a9a0337 100644 --- a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift +++ b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift @@ -1,68 +1,68 @@ import Foundation public class URLRequestBuilder { - + var endpoint: String var path: String? var headers: [String: String] = [:] var queryItems: [String: String?] = [:] var method: String? var body: Data? - + public init(endpoint: String) { self.endpoint = endpoint } - + public func setPath(_ path: String) -> Self { self.path = path return self } - + public func addHeader(_ name: String, _ value: String) -> Self { self.headers[name] = value return self } - + public func setHeaders(_ headers: [String: String]) -> Self { self.headers = headers return self } - + public func addQueryItem(_ name: String, _ value: String?) -> Self { self.queryItems[name] = value return self } - + public func setQueryItems(_ queryItems: [String: String?]) -> Self { self.queryItems = queryItems return self } - + public func setQueryItems(_ queryItems: [(String, String)]) -> Self { self.queryItems = queryItems.reduce(into: [:]) { $0[$1.0] = $1.1 } return self } - + public func setMethod(_ method: HTTPMethod) -> Self { self.method = method.rawValue.uppercased() return self } - + public func setBody(_ body: T) -> Self { self.body = try? JSONEncoder.encoder.encode(body) return addHeader("Content-Type", "application/json") } - + public func setBody(_ body: Data) -> Self { self.body = body return self } - + public func build() throws -> URLRequest { guard let method = method else { throw URLRequestBuilderError.invalidMethod } - + let url = try buildURL() var request = URLRequest(url: url) request.allHTTPHeaderFields = headers @@ -70,49 +70,49 @@ public class URLRequestBuilder { request.httpBody = body return request } - + private func buildURL() throws -> URL { var components = URLComponents(string: endpoint) if !queryItems.isEmpty { components?.queryItems = queryItems.compactMap(buildQueryItem) } - + guard let url = components?.url else { throw URLRequestBuilderError.invalidUrl } - + if let path = path { return url.appendingPathComponent(path) } - + return url } - + private func buildQueryItem(_ name: String, value: String?) -> URLQueryItem? { guard let value = value else { return nil } - + return URLQueryItem(name: name, value: value) } } public extension URLRequestBuilder { - + func addBearerAuthorization(_ authorization: String) -> Self { return addHeader("Authorization", "Bearer \(authorization)") } - + func addQueryItem(_ name: String, _ value: T?) -> Self { return addQueryItem(name, value.flatMap(String.init)) } - + } public extension URLRequestBuilder { - + func asService() -> Service { return Service(self) } - + } diff --git a/Sources/PaystackSDK/Core/Utils/Cryptography/Asn1Parser.swift b/Sources/PaystackSDK/Core/Utils/Cryptography/Asn1Parser.swift index d4993b7..42c1a6d 100644 --- a/Sources/PaystackSDK/Core/Utils/Cryptography/Asn1Parser.swift +++ b/Sources/PaystackSDK/Core/Utils/Cryptography/Asn1Parser.swift @@ -10,46 +10,46 @@ import Foundation /// Simple data scanner that consumes bytes from a raw data and keeps an updated position. private class Scanner { - + enum ScannerError: Error { case outOfBounds } - + let data: Data var index: Int = 0 - + /// Returns whether there is no more data to consume var isComplete: Bool { return index >= data.count } - + /// Creates a scanner with provided data /// /// - Parameter data: Data to consume init(data: Data) { self.data = data } - + /// Consumes data of provided length and returns it /// /// - Parameter length: length of the data to consume /// - Returns: data consumed /// - Throws: ScannerError.outOfBounds error if asked to consume too many bytes func consume(length: Int) throws -> Data { - + guard length > 0 else { return Data() } - + guard index + length <= data.count else { throw ScannerError.outOfBounds } - + let subdata = data.subdata(in: index.. Int { - + let lengthByte = try consume(length: 1).firstByte - + // If the first byte's value is less than 0x80, it directly contains the length // so we can return it guard lengthByte >= 0x80 else { return Int(lengthByte) } - + // If the first byte's value is more than 0x80, it indicates how many following bytes // will describe the length. For instance, 0x85 indicates that 0x85 - 0x80 = 0x05 = 5 // bytes will describe the length, so we need to read the 5 next bytes and get their integer // value to determine the length. let nextByteCount = lengthByte - 0x80 let length = try consume(length: Int(nextByteCount)) - + return length.integer } } private extension Data { - + /// Returns the first byte of the current data var firstByte: UInt8 { var byte: UInt8 = 0 copyBytes(to: &byte, count: MemoryLayout.size) return byte } - + /// Returns the integer value of the current data. /// @warning: this only supports data up to 4 bytes, as we can only extract 32-bit integers. var integer: Int { - + guard count > 0 else { return 0 } - + var int: UInt32 = 0 var offset: Int32 = Int32(count - 1) forEach { byte in @@ -107,7 +107,7 @@ private extension Data { int = int | shifted offset -= 1 } - + return Int(int) } } @@ -116,7 +116,7 @@ private extension Data { /// The root node can be any of the supported nodes described in `Node`. If the parser encounters a sequence /// it will recursively parse its children. enum Asn1Parser { - + /// An ASN1 node enum Node { case sequence(nodes: [Node]) @@ -126,12 +126,12 @@ enum Asn1Parser { case bitString(data: Data) case octetString(data: Data) } - + enum ParserError: Error { case noType case invalidType(value: UInt8) } - + /// Parses ASN1 data and returns its root node. /// /// - Parameter data: ASN1 data to parse @@ -142,7 +142,7 @@ enum Asn1Parser { let node = try parseNode(scanner: scanner) return node } - + /// Parses an ASN1 given an existing scanne. /// @warning: this will modify the state (ie: position) of the provided scanner. /// @@ -150,9 +150,9 @@ enum Asn1Parser { /// - Returns: Parsed node /// - Throws: A ParserError if anything goes wrong, or if an unknown node was encountered private static func parseNode(scanner: Scanner) throws -> Node { - + let firstByte = try scanner.consume(length: 1).firstByte - + // Sequence if firstByte == 0x30 { let length = try scanner.consumeLength() @@ -160,49 +160,49 @@ enum Asn1Parser { let nodes = try parseSequence(data: data) return .sequence(nodes: nodes) } - + // Integer if firstByte == 0x02 { let length = try scanner.consumeLength() let data = try scanner.consume(length: length) return .integer(data: data) } - + // Object identifier if firstByte == 0x06 { let length = try scanner.consumeLength() let data = try scanner.consume(length: length) return .objectIdentifier(data: data) } - + // Null if firstByte == 0x05 { _ = try scanner.consume(length: 1) return .null } - + // Bit String if firstByte == 0x03 { let length = try scanner.consumeLength() - + // There's an extra byte (0x00) after the bit string length in all the keys I've encountered. // I couldn't find a specification that referenced this extra byte, but let's consume it and discard it. _ = try scanner.consume(length: 1) - + let data = try scanner.consume(length: length - 1) return .bitString(data: data) } - + // Octet String if firstByte == 0x04 { let length = try scanner.consumeLength() let data = try scanner.consume(length: length) return .octetString(data: data) } - + throw ParserError.invalidType(value: firstByte) } - + /// Parses an ASN1 sequence and returns its child nodes /// /// - Parameter data: ASN1 data diff --git a/Sources/PaystackSDK/Core/Utils/DateFormatter.swift b/Sources/PaystackSDK/Core/Utils/DateFormatter.swift index e72d82d..b2d4098 100644 --- a/Sources/PaystackSDK/Core/Utils/DateFormatter.swift +++ b/Sources/PaystackSDK/Core/Utils/DateFormatter.swift @@ -8,7 +8,7 @@ public extension DateFormatter { formatter.dateFormat = DateFormat.paystack.rawValue return formatter } - + static func toString(usingFormat format: String, from date: Date) -> String { let formatter = DateFormatter() formatter.locale = .current diff --git a/Sources/PaystackSDK/Core/Utils/JSONDecoder.swift b/Sources/PaystackSDK/Core/Utils/JSONDecoder.swift index b080529..ebdd63a 100644 --- a/Sources/PaystackSDK/Core/Utils/JSONDecoder.swift +++ b/Sources/PaystackSDK/Core/Utils/JSONDecoder.swift @@ -1,12 +1,12 @@ import Foundation extension JSONDecoder { - + static var decoder: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.dateDecodingStrategy = .formatted(.paystackFormatter) return decoder } - + } diff --git a/Sources/PaystackSDK/Core/Utils/JSONEncoder.swift b/Sources/PaystackSDK/Core/Utils/JSONEncoder.swift index 1c4b196..6f77a00 100644 --- a/Sources/PaystackSDK/Core/Utils/JSONEncoder.swift +++ b/Sources/PaystackSDK/Core/Utils/JSONEncoder.swift @@ -1,12 +1,12 @@ import Foundation extension JSONEncoder { - + static var encoder: JSONEncoder { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase encoder.dateEncodingStrategy = .formatted(.paystackFormatter) return encoder } - + } diff --git a/Sources/PaystackSDK/Core/Utils/Queryable.swift b/Sources/PaystackSDK/Core/Utils/Queryable.swift index a77e7a8..ef8e605 100644 --- a/Sources/PaystackSDK/Core/Utils/Queryable.swift +++ b/Sources/PaystackSDK/Core/Utils/Queryable.swift @@ -5,9 +5,9 @@ public protocol Queryable { } public extension Array where Element: Queryable { - + var keyPairs: [(String, String)] { return map { $0.keyPair } } - + } diff --git a/Sources/PaystackSDK/Versioning/update_version.sh b/Sources/PaystackSDK/Versioning/update_version.sh index 21fcbf9..0353943 100755 --- a/Sources/PaystackSDK/Versioning/update_version.sh +++ b/Sources/PaystackSDK/Versioning/update_version.sh @@ -15,7 +15,8 @@ fi VALUES=($(echo $CURRENT_VERSION | tr . '\n')) VALUE=VALUES[INDEX] -NEW_VALUE=$((VALUE+1)) +INCREMENT=1 +NEW_VALUE=$((VALUE + INCREMENT)) VALUES[INDEX]=$NEW_VALUE if [ $INDEX -lt 2 ]; then VALUES[2]=0; fi if [ $INDEX -lt 1 ]; then VALUES[1]=0; fi @@ -23,6 +24,6 @@ NEW_VERSION=$(echo $(IFS=. ; echo "${VALUES[*]}")) /usr/libexec/PlistBuddy -c "Set Version $NEW_VERSION" $PLIST /usr/libexec/PlistBuddy -c "Set Description $DESCRIPTION" $PLIST - +cd ../.. sed -i '' -e "s/.*s.version .*/ s.version = '$NEW_VERSION'/" PaystackCore.podspec sed -i '' -e "s/.*s.version .*/ s.version = '$NEW_VERSION'/" PaystackUI.podspec diff --git a/Sources/PaystackSDK/Versioning/versions.plist b/Sources/PaystackSDK/Versioning/versions.plist index 24ec383..45fa0d3 100644 --- a/Sources/PaystackSDK/Versioning/versions.plist +++ b/Sources/PaystackSDK/Versioning/versions.plist @@ -3,8 +3,8 @@ Description - Initial Release + Black Version - 0.0.0 + 0.0.1 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..a7af36f --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift @@ -0,0 +1,19 @@ +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.transaction, provider: response.data.transaction, channelName: response.data.transaction, 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..2b9c630 --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -0,0 +1,51 @@ +import Foundation +import PaystackCore + +class MPesaChrageViewModel: ObservableObject { + + 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, transactionId: "\(transactionDetails.transactionId ?? 0)", provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") + transactionState = .processTransaction(transaction: authenticationResult) + } catch { + let chargeError = ChargeError(error: error) + transactionState = .error(chargeError) + Logger.error("Submitting your phone number failed with error: %@", + arguments: error.localizedDescription) + } + } + + func cancelTransaction() { + // TODO: cancel transaction code + } +} + +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..fdbd26b --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift @@ -0,0 +1,63 @@ +import Foundation +class MPesaProcessingViewModel: ObservableObject { + + var chargeCardContainer: ChargeContainer + var repository: ChargeMobileMoneyRepository + var transactionDetails: VerifyAccessCode + let mobileMoneyTransaction: MobileMoneyTransaction + @Published + var counter = 0 + + init(transactionDetails: VerifyAccessCode, + chargeCardContainer: ChargeContainer, + mobileMoneyTransaction: MobileMoneyTransaction, + repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { + self.transactionDetails = transactionDetails + self.chargeCardContainer = chargeCardContainer + self.repository = repository + self.mobileMoneyTransaction = mobileMoneyTransaction + } + + func checkTransactionStatus() { + Task { + await checkPendingCharge() + } + } + + @MainActor + func initializeMPesaAuthorization() async { + do { + let authenticationResult = try await repository.listenForMPesa(for: Int( mobileMoneyTransaction.transaction) ?? 0) + + if authenticationResult.status == .success { + chargeCardContainer.processSuccessfulTransaction(details: transactionDetails) + } else { + print(authenticationResult.status.rawValue) + } + } catch { + let error = ChargeError(error: error) + //chargeCardContainer.displayTransactionError(error) + } + } + + @MainActor + private func checkPendingCharge() async { + do { + // chargeCardState = .loading(message: "Checking transaction status") + // try await Task.sleep(nanoseconds: checkPendingChargeDelay) + let authenticationResult = try await repository.checkPendingCharge( + with: transactionDetails.accessCode) + if authenticationResult.status == .success { + chargeCardContainer.processSuccessfulTransaction(details: transactionDetails) + } + } catch { + let error = ChargeError(error: error) + // displayTransactionError(error) + } + } + + func cancelTransaction() { + // chargeCardContainer.restartCardPayment() + } + +} diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift new file mode 100644 index 0000000..52584e1 --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -0,0 +1,115 @@ +// +// 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) + .background(Color.red) + .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..4345a0a --- /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, + automaticallyDismissWith: .init( + error: chargeError, + transactionReference: viewModel.transactionDetails.reference)) + case .fatalError(let error): + ErrorView(message: error.message, + automaticallyDismissWith: .init( + error: error, + transactionReference: viewModel.transactionDetails.reference)) + case .processTransaction(let transaction): + MPesaProcessingView(chargeCardContainer: viewModel.chargeCardContainer, transactionDetails: viewModel.transactionDetails, 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..dccab1c --- /dev/null +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct MPesaProcessingView: View { + + @StateObject + var viewModel: MPesaProcessingViewModel + + init(chargeCardContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + mobileMoneyTransaction: MobileMoneyTransaction) { + self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel(transactionDetails: transactionDetails, + chargeCardContainer: chargeCardContainer, + 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 0000000000000000000000000000000000000000..8fecfd70686c2573fe4d29a80862a3561b555f50 GIT binary patch literal 352 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`j)FbFd;%$g$s6l5$8 za(7}_cTVOdki(Mh=(4)dNBN{>FYCiMtJZSWRn-LIo>P2GURkwCQ_jbDmzvsR&5)153A^vIF63MB=(q>(q`LZjf?QSS-)9)r zU!Be;vd?a=dG)2n#V2_hJszdafAy<9)qgvyt4N7L-ti6j>;C_{%dK>+PvE(2B8yF| uetK5>ucjNB9#OBQSBL6o{r}MUmN8GLr8N4$Ln~kaFnGH9xvX{%S9?#d$aq@L67Q5Fc7orys3WbP9 zqg2Q_!?o3fxx5LrEX&jDOC%DWB6W?lH`J3g(v(Ik=$snEsQSa^{sFda-yiUAX2dgq z8Cc%h=3_~2$Hhm7EOoT;X;^dvgybzafEy~fGnJeS8Q`co*r>gm&ZgFPi% z$Cu})$Q}ORfZNDytQVx$3b0jcmXpm9foHh9Q=Oy(pZV>VmauAT2eafo_Wx?q3-dHP z24?9e?|@|8Pjp}%WH=p~xT$yjLaM2jWU==tgho6^MP;Ot(X~NW~adk;yMav4Xx*7^KuxPyk9_Mv% TvL?^T00000NkvXXu0mjfjvnND literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ef003369621c50ce1f8e9f80cff4f0a474d02928 GIT binary patch literal 410 zcmV;L0cHM)P)jL^#j=2Sc#2|g|&r+je#^CB*Gc4h<_k{ z0}C4kQ7|SJx!Y9h%xNL)9$b+JTkPzQ_hx2+gi2lecdG6`Kq)g2N{RBuD$>=OkK=5Gz<-xWE3b`eWkB#V#?XND*FI~*f z&R~H~GZx^prwfgF}}M_)$E)e-c@N{J)+qjv*C{y#cn_OpXGz*6Uh@6k9|*h1Yo~ ztc{(dwuI|t(&V5s`ktS}KXI?~oUG+5Qq67sRwT#Iaf<+}zu(2;^K*}ivhJ6A^X^Rb z=d?D_^42-R&8rwJjwV(F>YNeKoN9gPK!L&J^(mW$XCCo=m9=!)`Mhw$&ugMR_UQ>f zuh@TX#`o*mv%a6LblVr}lzhXaF#XQq^0(~&Od4h$aq&|7;<@P3`osLpmnZUu&Mg)` zmT|JX{r{Wg3N~93MaEUyAQMd?8gr zLn(aC=I=k(cr*1EUkLtr^lRD9OykZ~A32wPmz3a+xs+fSessZ8U_dZ Bool] = [] - + func execute(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { guard serviceExpectations.allSatisfy({ $0(request) }) else { completion(nil, nil, nil) return } - + completion(data, response, error) } - + } extension MockServiceExecutor { - + func expectURL(_ url: String) -> Self { serviceExpectations.append { request in request.url?.absoluteString == url } - + return self } - + func expectMethod(_ method: HTTPMethod) -> Self { serviceExpectations.append { request in request.httpMethod == method.rawValue.uppercased() } - + return self } - + func expectHeader(_ header: String, _ value: String) -> Self { serviceExpectations.append { request in request.allHTTPHeaderFields?[header] == value } - + return self } - + func expectBody(_ body: T) -> Self { serviceExpectations.append { request in request.httpBody == (try? JSONEncoder.encoder.encode(body)) } - + return self } - + func andReturn(json filename: String) { let bundle = Bundle.module let url = bundle.url(forResource: filename, withExtension: "json") self.data = url.flatMap { try? Data(contentsOf: $0) } self.response = url.flatMap { HTTPURLResponse(url: $0, statusCode: 200, httpVersion: nil, headerFields: nil) } } - + func andThrow(_ error: Error) { self.error = error } - + } diff --git a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift index d4981d8..35104c4 100644 --- a/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift +++ b/Tests/PaystackSDKTests/Core/PaystackBuilderTests.swift @@ -2,27 +2,27 @@ import XCTest import PaystackCore class PaystackBuilderTests: XCTestCase { - + var builder: PaystackBuilder! - + override func setUpWithError() throws { try super.setUpWithError() builder = .newInstance } - + func testBuildReturnsPaystackInstance() throws { XCTAssertNoThrow(try builder .setKey("testsk_exampleKey") .build()) } - + func testBuildThrowsErrorWhenNoAPIKeyProvided() throws { do { - let _ = try builder.build() + _ = try builder.build() XCTFail("Builder did not throw noAPIKey error") } catch { XCTAssertEqual(PaystackError.noAPIKey, error as? PaystackError) } } - + } 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 5b598ad..2217980 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -30,7 +30,7 @@ final class ChargeRepositoryImplementationTests: PSTestCase { domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "203520101") + reference: "203520101", channelOptions: PaystackUI.ChannelOptions(mobileMoney: nil)) XCTAssertEqual(result, expectedResult) } diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 10d3400..c064379 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -35,7 +35,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 +68,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 +78,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) } From ba79687931c28b00a64b81d9eba7e2616463f90f Mon Sep 17 00:00:00 2001 From: Peter-John Date: Mon, 26 Feb 2024 14:47:05 +0200 Subject: [PATCH 07/14] correct mapping of transaction data --- .../Charge/MobileMoney/Models/MobileMoneyTransaction.swift | 7 ++++++- .../Charge/MobileMoney/Views/ChannelSelectionView.swift | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift index a7af36f..1ae0708 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyTransaction.swift @@ -13,7 +13,12 @@ struct MobileMoneyTransaction: Equatable { extension MobileMoneyTransaction { static func from(_ response: MobileMoneyChargeResponse) -> Self { - MobileMoneyTransaction(transaction: response.data.transaction, phone: response.data.transaction, provider: response.data.transaction, channelName: response.data.transaction, timer: response.data.display.timer, message: response.data.display.message) + 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/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index 52584e1..c5731bb 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -105,7 +105,6 @@ struct ChannelView: View { Spacer() } .padding(.doublePadding) - .background(Color.red) .cornerRadius(.cornerRadius) .overlay( RoundedRectangle(cornerRadius: .cornerRadius) From c073940d7b54929fc9e20acc283ade682ad5cf65 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 21 Apr 2026 09:42:44 +0200 Subject: [PATCH 08/14] Complete M-Pesa error handling to mirror card flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MPesaProcessingViewModel previously caught network errors and then discarded them (commented-out display call), and routed non-success transaction statuses to print(). Errors surfaced to the user only if they originated in MPesaChrageViewModel.submitPhoneNumber, and even then both .error and .fatalError auto-dismissed the flow — so the customer was kicked out of the payment on recoverable failures instead of being given a retry. Introduce an MPesaContainer protocol on MPesaChrageViewModel mirroring ChargeCardContainer. MPesaProcessingViewModel now routes both listenForMPesa and checkPendingCharge through the container — success and non-success statuses go to processTransactionResponse, thrown errors go to displayTransactionError. The container maps .failed to .error (retry) and .timeout / unexpected statuses to .fatalError (auto-dismiss), matching ChargeCardViewModel. MPesaChargeView now splits .error (shows "Try again" retry button wired to restartMPesaPayment) from .fatalError (auto-dismiss with ChargeErrorDetails), so recoverable errors no longer terminate the payment. TweetNacl/CTweetNacl Podfile fix: Fix CTweetNacl module resolution in Example Pods build Xcode 16+ / Swift 6 explicit-module builds fail to build PusherSwift with "Unable to resolve module dependency: 'CTweetNacl'" because TweetNacl's C submodule is declared in Pods/TweetNacl/Sources/module.map but only TweetNacl's own target xcconfig gets that path on SWIFT_INCLUDE_PATHS — consumers importing TweetNacl cannot find CTweetNacl. Extend the Podfile post_install hook to append "${PODS_ROOT}/TweetNacl/Sources" to SWIFT_INCLUDE_PATHS on every generated pod target so PusherSwift (and anything transitively importing TweetNacl) can resolve the C submodule. --- Example/Podfile | 8 +++ .../Viewmodels/MPesaChrageViewModel.swift | 50 ++++++++++++++++--- .../Viewmodels/MPesaProcessingViewModel.swift | 43 ++++++++-------- .../MobileMoney/Views/MPesaChargeView.swift | 8 +-- .../Views/MPesaProcessingView.swift | 9 ++-- 5 files changed, 80 insertions(+), 38 deletions(-) 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/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift index 2b9c630..78d3e0b 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -1,7 +1,14 @@ import Foundation import PaystackCore -class MPesaChrageViewModel: ObservableObject { +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 @@ -27,16 +34,47 @@ class MPesaChrageViewModel: ObservableObject { @MainActor func submitPhoneNumber() async { do { - let authenticationResult = try await repository.chargeMobileMoney(phone: phoneNumber, transactionId: "\(transactionDetails.transactionId ?? 0)", provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") + let authenticationResult = try await repository.chargeMobileMoney( + phone: phoneNumber, + transactionId: "\(transactionDetails.transactionId ?? 0)", + provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") transactionState = .processTransaction(transaction: authenticationResult) } catch { - let chargeError = ChargeError(error: error) - transactionState = .error(chargeError) - Logger.error("Submitting your phone number failed with error: %@", - arguments: error.localizedDescription) + 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() { // TODO: cancel transaction code } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift index fdbd26b..f8613b8 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift @@ -1,23 +1,26 @@ import Foundation +import PaystackCore + class MPesaProcessingViewModel: ObservableObject { - var chargeCardContainer: ChargeContainer + var container: MPesaContainer var repository: ChargeMobileMoneyRepository - var transactionDetails: VerifyAccessCode let mobileMoneyTransaction: MobileMoneyTransaction @Published var counter = 0 - init(transactionDetails: VerifyAccessCode, - chargeCardContainer: ChargeContainer, + init(container: MPesaContainer, mobileMoneyTransaction: MobileMoneyTransaction, repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) { - self.transactionDetails = transactionDetails - self.chargeCardContainer = chargeCardContainer + self.container = container self.repository = repository self.mobileMoneyTransaction = mobileMoneyTransaction } + var transactionDetails: VerifyAccessCode { + container.transactionDetails + } + func checkTransactionStatus() { Task { await checkPendingCharge() @@ -27,37 +30,31 @@ class MPesaProcessingViewModel: ObservableObject { @MainActor func initializeMPesaAuthorization() async { do { - let authenticationResult = try await repository.listenForMPesa(for: Int( mobileMoneyTransaction.transaction) ?? 0) - - if authenticationResult.status == .success { - chargeCardContainer.processSuccessfulTransaction(details: transactionDetails) - } else { - print(authenticationResult.status.rawValue) - } + let authenticationResult = try await repository.listenForMPesa( + for: Int(mobileMoneyTransaction.transaction) ?? 0) + await container.processTransactionResponse(authenticationResult) } catch { - let error = ChargeError(error: error) - //chargeCardContainer.displayTransactionError(error) + Logger.error("Listening for M-Pesa transaction failed with error: %@", + arguments: error.localizedDescription) + container.displayTransactionError(ChargeError(error: error)) } } @MainActor private func checkPendingCharge() async { do { - // chargeCardState = .loading(message: "Checking transaction status") - // try await Task.sleep(nanoseconds: checkPendingChargeDelay) let authenticationResult = try await repository.checkPendingCharge( with: transactionDetails.accessCode) - if authenticationResult.status == .success { - chargeCardContainer.processSuccessfulTransaction(details: transactionDetails) - } + await container.processTransactionResponse(authenticationResult) } catch { - let error = ChargeError(error: error) - // displayTransactionError(error) + Logger.error("Checking pending M-Pesa charge failed with error: %@", + arguments: error.localizedDescription) + container.displayTransactionError(ChargeError(error: error)) } } func cancelTransaction() { - // chargeCardContainer.restartCardPayment() + container.restartMPesaPayment() } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift index 4345a0a..04239cc 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift @@ -21,16 +21,16 @@ struct MPesaChargeView: View { LoadingView(message: message) case .error(let chargeError): ErrorView(message: chargeError.message, - automaticallyDismissWith: .init( - error: chargeError, - transactionReference: viewModel.transactionDetails.reference)) + 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(chargeCardContainer: viewModel.chargeCardContainer, transactionDetails: viewModel.transactionDetails, mobileMoneyTransaction: transaction) + MPesaProcessingView(container: viewModel, + mobileMoneyTransaction: transaction) case .countdown: VStack(spacing: .triplePadding) { diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift index dccab1c..5d6e54e 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift @@ -6,12 +6,11 @@ struct MPesaProcessingView: View { @StateObject var viewModel: MPesaProcessingViewModel - init(chargeCardContainer: ChargeContainer, - transactionDetails: VerifyAccessCode, + init(container: MPesaContainer, mobileMoneyTransaction: MobileMoneyTransaction) { - self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel(transactionDetails: transactionDetails, - chargeCardContainer: chargeCardContainer, - mobileMoneyTransaction: mobileMoneyTransaction)) + self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel( + container: container, + mobileMoneyTransaction: mobileMoneyTransaction)) } var body: some View { From cb67eac4786009cb3b17d58ea6f296a88fea2a6d Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 21 Apr 2026 10:44:02 +0200 Subject: [PATCH 09/14] Pin CI actions to SHAs and bump Xcode/iOS toolchain Pin every third-party action to a full commit SHA (with a version comment) so workflow supply-chain changes are explicit: - actions/checkout v4 -> v6.0.2 - maxim-lobanov/setup-xcode v1 -> v1.7.0 - maxim-lobanov/setup-cocoapods v1 -> v1.4.0 - peter-evans/create-pull-request v4 -> v8.1.1 - ruby/setup-ruby v1 -> v1.302.0 - danger-swift-with-swiftlint 3.15.0 -> 3.22.1 Replace the archived actions/create-release@v1 with softprops/action-gh-release@v3.0.0 (release_name -> name). Bump the build toolchain off the retired macos-12 / macos-14 runners and the Xcode 15.3 / iOS 17.4 simulator: - runners: macos-12 / macos-14 / macos-latest -> macos-15 - Xcode: 15.3.0 -> 26.3 - simulator: iPhone 15 Pro / iOS 17.4 -> iPhone 17 Pro / iOS 26.2 macos-26 is GA but currently ships without all iOS 26 simulator runtimes on arm64, so macos-15 is the safer landing point. --- .github/workflows/build.yml | 34 +++++++++++++++++----------------- .github/workflows/deploy.yml | 21 ++++++++++----------- .github/workflows/primary.yml | 26 +++++++++++++------------- .github/workflows/release.yml | 8 ++++---- 4 files changed, 44 insertions(+), 45 deletions(-) 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 4e25cd0..7dff396 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,16 +5,16 @@ on: jobs: deploy: - runs-on: macos-latest + 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: | @@ -22,10 +22,10 @@ jobs: - 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 @@ -45,12 +45,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: @@ -58,9 +58,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' @@ -72,4 +72,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 From 38a4b942fb6f222ee843d443bc07e4907f83bd66 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 21 Apr 2026 12:04:46 +0200 Subject: [PATCH 10/14] Add unit tests for Mobile Money flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the M-Pesa feature at the three layers the rest of the SDK tests use (repository, ViewModel, charge-API): * ChargeMobileMoneyRepositoryImplementationTests — chargeMobileMoney (POST /charge/mobile_money), listenForMPesa (Pusher MOBILE_MONEY_ channel, success and failed payloads), checkPendingCharge. * MPesaChrageViewModelTests — phone validation, submitPhoneNumber success/error paths, every processTransactionResponse branch (success/failed/timeout/pending/unexpected), displayTransactionError, restart, cancel. * MPesaProcessingViewModelTests — initializeMPesaAuthorization success/error, fire-and-forget checkTransactionStatus driven through a closure hook + fulfillment, cancelTransaction, transactionDetails proxy. * MockChargeMobileMoneyRepository and MockMPesaContainer follow the shape of the existing MockChargeCardRepository / MockChargeContainer. Also picks up a few small drive-by fixes on the production side: * MPesaChrageViewModel.cancelTransaction now calls restartMPesaPayment instead of a TODO. * ChargeCardViewModel marks its ChargeCardContainer conformance @MainActor, matching MPesaChrageViewModel. * Drop leftover print() calls in PusherSubscriptionListener. * Drop the staging-URL comment in PaystackService.endpoint. * Reformat ChargeTests.swift (whitespace only). --- .../Core/Service/PaystackService.swift | 1 - .../PusherSubscriptionListener.swift | 3 - .../Container/ChargeCardViewModel.swift | 2 +- .../Viewmodels/MPesaChrageViewModel.swift | 2 +- .../API/Charge/ChargeTests.swift | 89 +++---- .../MPesaChrageViewModelTests.swift | 227 ++++++++++++++++++ .../MPesaProcessingViewModelTests.swift | 118 +++++++++ ...leMoneyRepositoryImplementationTests.swift | 94 ++++++++ .../MockChargeMobileMoneyRepository.swift | 39 +++ .../UI/Charge/Mocks/MockMPesaContainer.swift | 28 +++ 10 files changed, 553 insertions(+), 50 deletions(-) create mode 100644 Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/MPesaProcessing/MPesaProcessingViewModelTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/MobileMoneyRepository/ChargeMobileMoneyRepositoryImplementationTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeMobileMoneyRepository.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/Mocks/MockMPesaContainer.swift diff --git a/Sources/PaystackSDK/Core/Service/PaystackService.swift b/Sources/PaystackSDK/Core/Service/PaystackService.swift index 8188b2a..0e945a7 100644 --- a/Sources/PaystackSDK/Core/Service/PaystackService.swift +++ b/Sources/PaystackSDK/Core/Service/PaystackService.swift @@ -9,7 +9,6 @@ public extension PaystackService { var endpoint: String { return "https://api.paystack.co/\(parentPath)" - //return "https://studio-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 25e047d..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() - print(channelName) listenForData(on: channel, forEvent: eventName, subscriptionResponse: completion) } @@ -53,7 +52,6 @@ struct PusherSubscriptionListener: SubscriptionListener { on channel: PusherChannel, forEvent eventName: String, subscriptionResponse: @escaping (Result) -> Void ) { - print(channel.subscriptionCount ?? 0) channel.bind(eventName: eventName, eventCallback: { guard let stringData = $0.data else { subscriptionResponse(.failure(.noData)) @@ -61,7 +59,6 @@ struct PusherSubscriptionListener: SubscriptionListener { pusher.disconnect() return } - print(channel.subscriptionCount ?? 0) subscriptionResponse(.success(stringData)) pusher.unsubscribe($0.channelName ?? "") pusher.disconnect() 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/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift index 78d3e0b..ae04933 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -76,7 +76,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { } func cancelTransaction() { - // TODO: cancel transaction code + restartMPesaPayment() } } diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index 13b0271..d553297 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -3,72 +3,73 @@ 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 - + func testMobileMoneyCharge() throws { let mobileMoneyRequestBody = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_1504248187", phone: "0723362418", transaction: "1504248187", provider: "MPESA") - + mockServiceExecutor .expectURL("https://api.paystack.co/charge/mobile_money") .expectMethod(.post) .expectHeader("Authorization", "Bearer \(apiKey)") .expectBody(mobileMoneyRequestBody) .andReturn(json: "ChargeMobileMoneyResponse") - + let mobileMoneyData = MobileMoneyData(phone: "0723362418", transaction: "1504248187", provider: "MPESA") - + _ = try serviceUnderTest.chargeMobileMoney(with: mobileMoneyData).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() + // 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 } -// swiftlint:enable file_length type_body_length line_length diff --git a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift new file mode 100644 index 0000000..36797ed --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift @@ -0,0 +1,227 @@ +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, "0703362111") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.transactionId, "\(expectedTransactionId)") + XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.provider, "MPESA") + } + + 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 + } +} From 6062c18e235048d5aa2b1c0c5be1b63fe953f9db Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 21 Apr 2026 12:50:26 +0200 Subject: [PATCH 11/14] Updated the unit tests --- .../PaystackSDKTests/API/Charge/ChargeTests.swift | 15 --------------- .../ChargeRepositoryImplementationTests.swift | 7 ++++++- .../UI/Charge/ChargeViewModelTests.swift | 13 +++++++++++-- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift index d553297..fc2cf67 100644 --- a/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ChargeTests.swift @@ -19,21 +19,6 @@ final class ChargeTests: PSTestCase { // TODO: testAuthenticateChargeWithPhoneAuthentication - func testMobileMoneyCharge() throws { - let mobileMoneyRequestBody = MobileMoneyChargeRequest(channelName: "MOBILE_MONEY_1504248187", phone: "0723362418", transaction: "1504248187", provider: "MPESA") - - mockServiceExecutor - .expectURL("https://api.paystack.co/charge/mobile_money") - .expectMethod(.post) - .expectHeader("Authorization", "Bearer \(apiKey)") - .expectBody(mobileMoneyRequestBody) - .andReturn(json: "ChargeMobileMoneyResponse") - - let mobileMoneyData = MobileMoneyData(phone: "0723362418", transaction: "1504248187", provider: "MPESA") - - _ = try serviceUnderTest.chargeMobileMoney(with: mobileMoneyData).sync() - } - func testAuthenticateChargeWithPhoneAuthentication() throws { let phoneRequestBody = SubmitPhoneRequest(phone: "0111234567", accessCode: "abcde") // TODO: Add Test for testAuthenticateChargeWithBirthdayAuthentication diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 2217980..99e1f78 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -23,6 +23,7 @@ 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", @@ -30,7 +31,11 @@ final class ChargeRepositoryImplementationTests: PSTestCase { domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "203520101", channelOptions: PaystackUI.ChannelOptions(mobileMoney: nil)) + 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 c064379..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 { From e3e747aa753f4a846d791fc7aaa9dad83500a431 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Wed, 22 Apr 2026 09:23:01 +0200 Subject: [PATCH 12/14] Normalize Kenyan M-Pesa phone numbers before charge submission Adds a `formattedKenyanPhoneNumber` String extension that accepts the three common user-entered formats (leading 0, 254, or +254) and normalizes them to a consistent +254 E.164 form. Applied in `MPesaChrageViewModel.submitPhoneNumber` so the repository always receives a uniformly formatted number. Covered by new `StringExtensionsTests` and additional `MPesaChrageViewModelTests` cases for each accepted prefix. --- .../Viewmodels/MPesaChrageViewModel.swift | 2 +- .../PaystackUI/Utils/StringExtensions.swift | 14 ++++++++ .../MPesaChrageViewModelTests.swift | 20 +++++++++++- .../UI/Utils/StringExtensionsTests.swift | 32 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 Tests/PaystackSDKTests/UI/Utils/StringExtensionsTests.swift diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift index ae04933..3299a15 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift @@ -35,7 +35,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer { func submitPhoneNumber() async { do { let authenticationResult = try await repository.chargeMobileMoney( - phone: phoneNumber, + phone: phoneNumber.formattedKenyanPhoneNumber, transactionId: "\(transactionDetails.transactionId ?? 0)", provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "") transactionState = .processTransaction(transaction: authenticationResult) 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/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift index 36797ed..da143d5 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MPesaCharge/MPesaChrageViewModelTests.swift @@ -57,11 +57,29 @@ final class MPesaChrageViewModelTests: XCTestCase { serviceUnderTest.phoneNumber = "0703362111" await serviceUnderTest.submitPhoneNumber() - XCTAssertEqual(mockRepository.chargeMobileMoneySubmitted.phone, "0703362111") + 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 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") + } +} From b3969621650bc7ac227e681c02e69b7a9d69cfc3 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Wed, 22 Apr 2026 09:59:02 +0200 Subject: [PATCH 13/14] cleaning up deployment yaml --- .github/workflows/deploy.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7dff396..07fe6bb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,10 +35,7 @@ jobs: echo "version=${version}" >> $GITHUB_ENV body=$(/usr/libexec/PlistBuddy -c "Print Description" ./Sources/PaystackSDK/Versioning/versions.plist) echo "body=${body}" >> $GITHUB_ENV - cd Sources/PaystackSDK/Core/Service/Subscription - PLIST=secrets.plist - /usr/libexec/PlistBuddy -c "Set PUSHER_API_KEY $PUSH_TOKEN" $PLIST - cd ../../../../.. + env: PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }} From 7d79955106c20c05bb03dad70b57e97a29daf33b Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Wed, 22 Apr 2026 10:00:25 +0200 Subject: [PATCH 14/14] Removed unused environment variable --- .github/workflows/deploy.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 07fe6bb..0359b98 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,10 +35,6 @@ jobs: echo "version=${version}" >> $GITHUB_ENV body=$(/usr/libexec/PlistBuddy -c "Print Description" ./Sources/PaystackSDK/Versioning/versions.plist) echo "body=${body}" >> $GITHUB_ENV - - env: - PUSH_TOKEN: ${{ secrets.PUSH_TOKEN }} - - name: Create Release id: create_release