diff --git a/Example/paystack-sdk-ios/ContentView.swift b/Example/paystack-sdk-ios/ContentView.swift index e536dbf..fcf727d 100644 --- a/Example/paystack-sdk-ios/ContentView.swift +++ b/Example/paystack-sdk-ios/ContentView.swift @@ -21,7 +21,17 @@ 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..7a921fc 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..230dc5c 100644 --- a/Sources/PaystackSDK/API/Charge/Charge.swift +++ b/Sources/PaystackSDK/API/Charge/Charge.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length type_body_length line_length import Foundation public extension Paystack { @@ -5,6 +6,10 @@ public extension Paystack { return ChargeServiceImplementation(config: config) } + private var mobileMoneyService: MobileMoneyService { + return MobileMoneyServiceImplementation(config: config) + } + /// Continues the Charge flow by authenticating a user with an OTP /// - Parameters: /// - otp: The OTP sent to the user's device @@ -73,4 +78,22 @@ public extension Paystack { return Service(subscription) } + /// Listens for a response after presenting a 3DS URL in a webview for authentication + /// - Parameter transactionId:The ID of the current transaction that is being authenticated + /// - Returns: A ``Service`` with the results of the authentication + func listenForMobileMoneyResponse(for transactionId: Int) -> Service { + let channelName = "MOBILE_MONEY_\(transactionId)" + let subscription: any Subscription = PusherSubscription(channelName: channelName, eventName: "response") + return Service(subscription) + } + + /// Initialize Mobile Money charge + /// - Parameters: + /// - mobileMoneyData: The data that needs to be passed in order to do a mobile money charge + /// - Returns: A ``Service`` with the ``MobileMoneyChargeResponse`` response + func chargeMobileMoney(with mobileMoneyData: MobileMoneyData) -> Service { + let request = MobileMoneyChargeRequest(channelName: mobileMoneyData.channelName, amount: mobileMoneyData.amount, email: mobileMoneyData.email, phone: mobileMoneyData.phone, transaction: mobileMoneyData.transaction, provider: mobileMoneyData.provider) + return mobileMoneyService.postChargeMobileMoney(request) + } } +// swiftlint:enable file_length type_body_length line_length diff --git a/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift new file mode 100644 index 0000000..e2fc647 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/MobileMoneyService.swift @@ -0,0 +1,19 @@ +import Foundation + +protocol MobileMoneyService: PaystackService { + func postChargeMobileMoney(_ request: MobileMoneyChargeRequest) -> Service +} + +struct MobileMoneyServiceImplementation: MobileMoneyService { + + var config: PaystackConfig + + var parentPath: String { + return "charge" + } + + func postChargeMobileMoney(_ request: MobileMoneyChargeRequest) -> Service { + return post("/mobile_money", request) + .asService() + } +} diff --git a/Sources/PaystackSDK/Core/Models/MobileMoney.swift b/Sources/PaystackSDK/Core/Models/MobileMoney.swift new file mode 100644 index 0000000..ce88752 --- /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..cb8575a --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/MobileMoneyChargeResponse.swift @@ -0,0 +1,32 @@ +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: String + let phone: String + let provider: String + let channelName: String + let display: Display + + enum CodingKeys: String, CodingKey { + case transaction + case phone + case provider + case channelName + case display + } +} + +// MARK: - Display +public struct Display: Codable { + 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 new file mode 100644 index 0000000..a6075b7 --- /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..067a8a2 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 line_length import XCTest @testable import PaystackCore @@ -27,6 +28,21 @@ 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") @@ -90,4 +106,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() + } + } +// swiftlint:enable file_length type_body_length line_length diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json new file mode 100644 index 0000000..ba39200 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ChargeMobileMoneyResponse.json @@ -0,0 +1,15 @@ +{ + "status": true, + "message": "Charge attempted", + "data": { + "transaction": "1504248187", + "phone": "0703362111", + "provider": "MPESA", + "channel_name": "MOBILE_MONEY_1504248187", + "display": { + "type": "pop", + "message": "Please complete authorization process on your mobile phone", + "timer": 60 + } + } +} diff --git a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json index d0dafd2..aea7d2d 100644 --- a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json +++ b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json @@ -15,12 +15,27 @@ "card", "qr", "ussd", - "eft" + "eft", + "mobile_money" ], "channel_options": { "qr": [ "visa" ], + "mobile_money": [ + { + "key": "MPESA", + "value": "M-PESA", + "isNew": true, + "phoneNumberRegex": "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$" + }, + { + "key": "MPESA_OFF", + "value": "M-PESA", + "isNew": false, + "phoneNumberRegex": "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$" + } + ], "ussd": [ "737", "822", diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 2e4ca1e..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], + paymentChannels: [.card, .qr, .ussd, .mobileMoney], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key",