diff --git a/Package.swift b/Package.swift index 30f92d5..0efc075 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,8 @@ let package = Package( .copy("API/Charge/Resources/PayWithTransferPusherCreditReceived.json"), .copy("API/Charge/Resources/PayWithTransferPusherCreditPending.json"), .copy("API/Charge/Resources/PayWithTransferPusherCreditRejected.json"), - .copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json") + .copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json"), + .copy("API/Charge/Resources/ZapMandateResponse.json") ]) ] diff --git a/Sources/PaystackSDK/API/Charge/Zap.swift b/Sources/PaystackSDK/API/Charge/Zap.swift new file mode 100644 index 0000000..393cd71 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/Zap.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Public Zap surface. Used by the UI module to initiate a digital bank +/// mandate and listen for Event status updates ; can also be called +/// directly by integrators driving their own UI on top of `PaystackCore`. +public extension Paystack { + + private var zapService: ZapMandateService { + return ZapMandateServiceImplementation(config: config) + } + + /// Initiates a Zap digital bank mandate for the given supported-bank + /// entry + transaction. Returns the deeplink URL, QR image URL, and + /// the Pusher channel to subscribe to for status updates. + /// + /// - Parameter request: Combines the Zap `supported_banks.id`, the + /// numeric transaction id from `verify_access_code`, and the + /// customer's email (sent as `wallet_id` in the form body). + /// - Returns: A ``Service`` carrying a ``ZapMandateResponse``. + func initiateZapMandate(_ request: ZapMandateRequest) + -> Service { + return zapService.postZapMandate(request) + } + + /// Listens for Zap status updates on the Pusher channel returned by + /// ``initiateZapMandate(_:)``. The status taxonomy is shared with + /// Pay-with-Transfer, so this + /// helper returns the existing `PayWithTransferPusherResponse` shape. + /// + /// The underlying listener is single-shot per the existing + /// `PusherSubscriptionListener` contract ; callers that need to keep + /// listening through transient statuses must re-subscribe after each + /// event. + /// + /// - Parameter channelName: The `pusherChannel` value returned from + /// `initiateZapMandate` (e.g. `DBMAN_6222375579`). + /// - Returns: A ``Service`` carrying a ``PayWithTransferPusherResponse`` + /// on the first event the channel emits. + func listenForZapResponse(onChannel channelName: String) + -> Service { + let subscription: any Subscription = PusherSubscription( + channelName: channelName, eventName: "response") + return Service(subscription) + } +} diff --git a/Sources/PaystackSDK/API/Charge/ZapMandateService.swift b/Sources/PaystackSDK/API/Charge/ZapMandateService.swift new file mode 100644 index 0000000..415829b --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/ZapMandateService.swift @@ -0,0 +1,23 @@ +import Foundation + + +protocol ZapMandateService: PaystackService { + func postZapMandate(_ request: ZapMandateRequest) + -> Service +} + +struct ZapMandateServiceImplementation: ZapMandateService { + + var config: PaystackConfig + + var parentPath: String { "bank/digitalbankmandate" } + + var baseURL: String { "https://standard.paystack.co" } + + func postZapMandate(_ request: ZapMandateRequest) + -> Service { + return postForm("/\(request.id)/\(request.transactionId)", + ["wallet_id": request.walletId]) + .asService() + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/SupportedBank.swift b/Sources/PaystackSDK/Core/Models/Models/SupportedBank.swift new file mode 100644 index 0000000..38a955e --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/SupportedBank.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct SupportedBank: Decodable, Equatable { + public let id: Int + public let code: String + public let name: String? + public let slug: String? + + public init(id: Int, code: String, name: String? = nil, slug: String? = nil) { + self.id = id + self.code = code + self.name = name + self.slug = slug + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift index 24b1129..68de674 100644 --- a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift @@ -15,11 +15,14 @@ public struct VerifyAccessCodeData: Decodable { public var merchantChannelSettings: MerchantChannelSettings? public var publicEncryptionKey: String + public var supportedBanks: [SupportedBank]? + public init(id: Int?, email: String, amount: Decimal, reference: String, accessCode: String, merchantLogo: String? = nil, merchantName: String, domain: Domain, currency: String, channels: [Channel], channelOptions: ChannelOptions, merchantChannelSettings: MerchantChannelSettings? = nil, - publicEncryptionKey: String) { + publicEncryptionKey: String, + supportedBanks: [SupportedBank]? = nil) { self.id = id self.email = email self.amount = amount @@ -33,5 +36,6 @@ public struct VerifyAccessCodeData: Decodable { self.channelOptions = channelOptions self.merchantChannelSettings = merchantChannelSettings self.publicEncryptionKey = publicEncryptionKey + self.supportedBanks = supportedBanks } } diff --git a/Sources/PaystackSDK/Core/Models/Models/ZapMandateRequest.swift b/Sources/PaystackSDK/Core/Models/Models/ZapMandateRequest.swift new file mode 100644 index 0000000..63d0152 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/ZapMandateRequest.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct ZapMandateRequest: Equatable { + + public let id: Int + + public let transactionId: Int + + public let walletId: String + + public init(id: Int, transactionId: Int, walletId: String) { + self.id = id + self.transactionId = transactionId + self.walletId = walletId + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/ZapMandateResponse.swift b/Sources/PaystackSDK/Core/Models/Models/ZapMandateResponse.swift new file mode 100644 index 0000000..57d980d --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/ZapMandateResponse.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct ZapMandateResponse: Decodable, Equatable { + public let status: String + public let message: String + public let pusherChannel: String + public let paymentUrl: String + public let qrImage: String + + public init(status: String, + message: String, + pusherChannel: String, + paymentUrl: String, + qrImage: String) { + self.status = status + self.message = message + self.pusherChannel = pusherChannel + self.paymentUrl = paymentUrl + self.qrImage = qrImage + } +} diff --git a/Sources/PaystackSDK/Core/Service/PaystackService.swift b/Sources/PaystackSDK/Core/Service/PaystackService.swift index 0e945a7..6d889f5 100644 --- a/Sources/PaystackSDK/Core/Service/PaystackService.swift +++ b/Sources/PaystackSDK/Core/Service/PaystackService.swift @@ -3,12 +3,17 @@ import Foundation public protocol PaystackService: URLRequestBuilderHelper { var config: PaystackConfig { get set } var parentPath: String { get } + var baseURL: String { get } } public extension PaystackService { + var baseURL: String { + return "https://api.paystack.co" + } + var endpoint: String { - return "https://api.paystack.co/\(parentPath)" + return "\(baseURL)/\(parentPath)" } var bearerToken: String { diff --git a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift index a9a0337..6b8bad1 100644 --- a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift +++ b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilder.swift @@ -58,6 +58,18 @@ public class URLRequestBuilder { return self } + public func setFormBody(_ fields: [String: String]) -> Self { + let pairs = fields.map { key, value -> String in + let encodedKey = key.addingPercentEncoding( + withAllowedCharacters: .formURLEncodedAllowed) ?? key + let encodedValue = value.addingPercentEncoding( + withAllowedCharacters: .formURLEncodedAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + self.body = pairs.joined(separator: "&").data(using: .utf8) + return addHeader("Content-Type", "application/x-www-form-urlencoded") + } + public func build() throws -> URLRequest { guard let method = method else { throw URLRequestBuilderError.invalidMethod @@ -116,3 +128,13 @@ public extension URLRequestBuilder { } } + +private extension CharacterSet { + + static let formURLEncodedAllowed: CharacterSet = { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return allowed + }() + +} diff --git a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilderHelper.swift b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilderHelper.swift index 6dea65a..d1a7806 100644 --- a/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilderHelper.swift +++ b/Sources/PaystackSDK/Core/Service/URLRequest/URLRequestBuilderHelper.swift @@ -8,6 +8,7 @@ public protocol URLRequestBuilderHelper { func get() -> URLRequestBuilder func get(_ path: String) -> URLRequestBuilder func post(_ path: String, _ body: T) -> URLRequestBuilder + func postForm(_ path: String, _ fields: [String: String]) -> URLRequestBuilder func put(_ path: String, _ body: T) -> URLRequestBuilder } @@ -30,6 +31,13 @@ public extension URLRequestBuilderHelper { .setBody(body) } + func postForm(_ path: String, _ fields: [String: String]) -> URLRequestBuilder { + return builder + .setMethod(.post) + .setPath(path) + .setFormBody(fields) + } + func put(_ path: String, _ body: T) -> URLRequestBuilder { return builder .setMethod(.put) diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ZapMandateResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/ZapMandateResponse.json new file mode 100644 index 0000000..5c2b207 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ZapMandateResponse.json @@ -0,0 +1,7 @@ +{ + "status": "pending", + "message": "Transaction Initiated", + "pusher_channel": "DBMAN_6222375579", + "payment_url": "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu", + "qr_image": "https://paystack-production-zap-eu-west-1.s3.eu-west-1.amazonaws.com/merchant-payments/qr/f3k3t3c88ovR6P7CDkKu/qr_f3k3t3c88ovR6P7CDkKu.png" +} diff --git a/Tests/PaystackSDKTests/API/Charge/ZapTests.swift b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift new file mode 100644 index 0000000..2d2c811 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import PaystackCore + +final class ZapTests: PSTestCase { + + let apiKey = "testsk_Example" + + var serviceUnderTest: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + serviceUnderTest = try PaystackBuilder.newInstance + .setKey(apiKey) + .build() + } + + // MARK: - initiateZapMandate + + func testInitiateZapMandateHitsStandardHostNotApiHost() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .expectHeader("Content-Type", "application/x-www-form-urlencoded") + .andReturn(json: "ZapMandateResponse") + + let request = ZapMandateRequest(id: 870, + transactionId: 6222375579, + walletId: "customer@example.com") + _ = try await serviceUnderTest.initiateZapMandate(request).async() + } + + func testInitiateZapMandateDecodesAllFieldsFromResponse() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ZapMandateResponse") + + let request = ZapMandateRequest(id: 870, + transactionId: 6222375579, + walletId: "customer@example.com") + let result = try await serviceUnderTest.initiateZapMandate(request).async() + + XCTAssertEqual(result.status, "pending") + XCTAssertEqual(result.message, "Transaction Initiated") + XCTAssertEqual(result.pusherChannel, "DBMAN_6222375579") + XCTAssertEqual(result.paymentUrl, + "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu") + XCTAssertTrue(result.qrImage.hasPrefix("https://")) + } + + // MARK: - listenForZapResponse + + func testListenForZapResponseSubscribesToProvidedChannelWithResponseEvent() async throws { + let channelName = "DBMAN_6222375579" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + let result = try await serviceUnderTest + .listenForZapResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "success") + } +} diff --git a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json index 945b03f..ff3a44e 100644 --- a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json +++ b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json @@ -54,6 +54,10 @@ "bank_transfer": { "fulfil_late_notification": true } - } + }, + "supported_banks": [ + { "id": 870, "code": "00zap", "name": "Zap by Paystack", "slug": "zap" }, + { "id": 871, "code": "044", "name": "Access Bank", "slug": "access-bank" } + ] } } diff --git a/Tests/PaystackSDKTests/API/Transactions/TransactionsTests.swift b/Tests/PaystackSDKTests/API/Transactions/TransactionsTests.swift index 5a65325..ba26a3d 100644 --- a/Tests/PaystackSDKTests/API/Transactions/TransactionsTests.swift +++ b/Tests/PaystackSDKTests/API/Transactions/TransactionsTests.swift @@ -34,4 +34,23 @@ class TransactionsTests: PSTestCase { _ = try await serviceUnderTest.checkPendingCharge(forAccessCode: "access_code_test").async() } + func testVerifyAccessCodeDecodesSupportedBanksFromResponse() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/verify_code/access_code_test") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "VerifyAccessCode") + + let result = try await serviceUnderTest + .verifyAccessCode("access_code_test").async() + + let supportedBanks = try XCTUnwrap(result.data.supportedBanks) + XCTAssertEqual(supportedBanks.count, 2) + XCTAssertEqual(supportedBanks[0].id, 870) + XCTAssertEqual(supportedBanks[0].code, "00zap") + XCTAssertEqual(supportedBanks[0].name, "Zap by Paystack") + XCTAssertEqual(supportedBanks[0].slug, "zap") + XCTAssertEqual(supportedBanks[1].code, "044") + } + } diff --git a/Tests/PaystackSDKTests/Core/URLRequestBuilderTests.swift b/Tests/PaystackSDKTests/Core/URLRequestBuilderTests.swift index 6bc18fd..e82791e 100644 --- a/Tests/PaystackSDKTests/Core/URLRequestBuilderTests.swift +++ b/Tests/PaystackSDKTests/Core/URLRequestBuilderTests.swift @@ -127,4 +127,61 @@ class URLRequestBuilderTests: XCTestCase { XCTAssertNotNil(result.value(forHTTPHeaderField: "x-platform-version")) XCTAssertNotNil(result.value(forHTTPHeaderField: "x-device")) } + + // MARK: - setFormBody — application/x-www-form-urlencoded + + func testSetFormBodyBuildsURLRequestWithFormUrlencodedContentType() throws { + let result = try builder + .setMethod(.post) + .setFormBody(["wallet_id": "test@example.com"]) + .build() + + XCTAssertEqual(result.value(forHTTPHeaderField: "Content-Type"), + "application/x-www-form-urlencoded") + } + + func testSetFormBodyEncodesSimpleField() throws { + let result = try builder + .setMethod(.post) + .setFormBody(["wallet_id": "alice"]) + .build() + + let body = try XCTUnwrap(result.httpBody) + XCTAssertEqual(String(data: body, encoding: .utf8), "wallet_id=alice") + } + + func testSetFormBodyPercentEncodesEmailValue() throws { + let result = try builder + .setMethod(.post) + .setFormBody(["wallet_id": "customer@example.com"]) + .build() + + let body = try XCTUnwrap(result.httpBody) + XCTAssertEqual(String(data: body, encoding: .utf8), + "wallet_id=customer%40example.com") + } + + func testSetFormBodyPercentEncodesAmpersandsAndSpacesInValue() throws { + let result = try builder + .setMethod(.post) + .setFormBody(["note": "hello & welcome"]) + .build() + + let body = try XCTUnwrap(result.httpBody) + XCTAssertEqual(String(data: body, encoding: .utf8), + "note=hello%20%26%20welcome") + } + + func testSetFormBodyJoinsMultipleFieldsWithAmpersand() throws { + let result = try builder + .setMethod(.post) + .setFormBody(["a": "1", "b": "2"]) + .build() + + let body = try XCTUnwrap(result.httpBody) + + let text = String(data: body, encoding: .utf8) + XCTAssertTrue(text == "a=1&b=2" || text == "b=2&a=1", + "Unexpected encoded body: \(text ?? "nil")") + } }