From 6ec6a5dab0e68245a2b832da78e8b5da8c65d26c Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 22 Jun 2026 16:26:14 +0200 Subject: [PATCH 1/2] Add Zap digital bank mandate support to PaystackCore Introduce the Zap mandate API on the public `Paystack` surface: `initiateZapMandate(_:)` initiates a mandate and `listenForZapResponse(onChannel:)` subscribes for status updates (reusing the Pay-with-Transfer Pusher response shape). - ZapMandateService posts to `bank/digitalbankmandate/{id}/{transactionId}`, overriding `baseURL` to `https://standard.paystack.co` - Make `baseURL` an overridable PaystackService requirement (defaults to api.paystack.co) so services can target a non-default host - Add `postForm`/`setFormBody` for application/x-www-form-urlencoded bodies with percent-encoded fields - Add ZapMandateRequest/ZapMandateResponse and SupportedBank models - Decode `supported_banks` from the verify-access-code response Tests: Zap initiate/listen, form-body encoding, and supported-banks decoding. --- Package.swift | 3 +- Sources/PaystackSDK/API/Charge/Zap.swift | 45 +++++++++++++ .../API/Charge/ZapMandateService.swift | 23 +++++++ .../Core/Models/Models/SupportedBank.swift | 15 +++++ .../VerifyAccessCodeData.swift | 6 +- .../Models/Models/ZapMandateRequest.swift | 16 +++++ .../Models/Models/ZapMandateResponse.swift | 21 ++++++ .../Core/Service/PaystackService.swift | 7 +- .../URLRequest/URLRequestBuilder.swift | 22 +++++++ .../URLRequest/URLRequestBuilderHelper.swift | 8 +++ .../Charge/Resources/ZapMandateResponse.json | 7 ++ .../API/Charge/ZapTests.swift | 66 +++++++++++++++++++ .../Resources/VerifyAccessCode.json | 6 +- .../API/Transactions/TransactionsTests.swift | 19 ++++++ .../Core/URLRequestBuilderTests.swift | 57 ++++++++++++++++ 15 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 Sources/PaystackSDK/API/Charge/Zap.swift create mode 100644 Sources/PaystackSDK/API/Charge/ZapMandateService.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/SupportedBank.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/ZapMandateRequest.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/ZapMandateResponse.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/ZapMandateResponse.json create mode 100644 Tests/PaystackSDKTests/API/Charge/ZapTests.swift 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")") + } } From 7ccf8df7c1d3b0c52a334e868ada988e03e3c6f8 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 23 Jun 2026 09:30:16 +0200 Subject: [PATCH 2/2] Add Zap payment method to charge UI flow Introduce Zap as a new payment channel in the PaystackUI charge flow, built on the Zap digital bank mandate support added to PaystackCore. - Add the Zap flow following the established Views/Viewmodels/Models/ Repository layout: ZapView, ZapPaymentView, ZapSessionExpiredView, ZapViewModel, ZapRepository, and the ZapConfig/ZapDetails/ZapState models, plus a QRCodeImage component. - Wire Zap into ChargeViewModel: surface it as a SupportedChannel when the access code advertises a promoted Zap bank (code "00zap"), and route single-channel Zap transactions straight to the payment screen. - Extend ChargePaymentType, SupportedChannel, and VerifyAccessCode (email + supportedBanks) to carry Zap, and render it from ChargeView. - ZapViewModel initiates the mandate, displays the QR/payment URL, counts down the 5-minute mandate window to a session-expired state, and listens on Pusher for the terminal transaction update. - Add zapLogo/zapSingleLogo image assets. - Add ZapViewModelTests and MockZapRepository, and extend ChargeViewModel/ChargeRepository tests for the Zap channel. --- Sources/PaystackUI/Charge/ChargeView.swift | 4 + .../PaystackUI/Charge/ChargeViewModel.swift | 19 ++ .../Views/ChannelSelectionView.swift | 11 +- .../Charge/Models/ChargePaymentType.swift | 2 + .../Charge/Models/SupportedChannel.swift | 7 + .../Charge/Models/VerifyAccessCode.swift | 16 +- .../Charge/Zap/Components/QRCodeImage.swift | 89 +++++ .../Charge/Zap/Models/ZapConfig.swift | 8 + .../Charge/Zap/Models/ZapDetails.swift | 35 ++ .../Charge/Zap/Models/ZapState.swift | 14 + .../Charge/Zap/Repository/ZapRepository.swift | 37 ++ .../Charge/Zap/Viewmodels/ZapViewModel.swift | 157 +++++++++ .../Charge/Zap/Views/ZapPaymentView.swift | 124 +++++++ .../Zap/Views/ZapSessionExpiredView.swift | 41 +++ .../PaystackUI/Charge/Zap/Views/ZapView.swift | 47 +++ .../zapLogo.imageset/Contents.json | 21 ++ .../zapLogo.imageset/full_zap.png | Bin 0 -> 11514 bytes .../zapSingleLogo.imageset/Contents.json | 21 ++ .../zapSingleLogo.imageset/logo_zap.png | Bin 0 -> 3063 bytes .../ChargeRepositoryImplementationTests.swift | 11 +- .../UI/Charge/ChargeViewModelTests.swift | 124 ++++++- .../UI/Charge/Mocks/MockZapRepository.swift | 46 +++ .../UI/Charge/Zap/ZapViewModelTests.swift | 319 ++++++++++++++++++ 23 files changed, 1143 insertions(+), 10 deletions(-) create mode 100644 Sources/PaystackUI/Charge/Zap/Components/QRCodeImage.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Models/ZapConfig.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Models/ZapDetails.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Models/ZapState.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Views/ZapSessionExpiredView.swift create mode 100644 Sources/PaystackUI/Charge/Zap/Views/ZapView.swift create mode 100644 Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/full_zap.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/logo_zap.png create mode 100644 Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index 1e7b5b8..729d181 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -66,6 +66,10 @@ struct ChargeView: View { BankTransferView(chargeContainer: viewModel, transactionDetails: transactionInformation, config: config) + case .zap(let transactionInformation, let config): + ZapView(chargeContainer: viewModel, + transactionDetails: transactionInformation, + config: config) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 0f3b291..4b1a86a 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -66,6 +66,16 @@ class ChargeViewModel: ObservableObject { result.append(.bankTransfer(config)) } + if response.paymentChannels.contains(.bank), + let banks = response.supportedBanks, + let zap = banks.first(where: { Self.promotedSupportedBankCodes.contains($0.code) }), + let transactionId = response.transactionId { + let config = ZapConfig(supportedBankId: zap.id, + transactionId: transactionId, + walletEmail: response.email) + result.append(.zap(config)) + } + return result } @@ -96,6 +106,13 @@ class ChargeViewModel: ObservableObject { config: config)) } + if !channels.contains(.card), + channels.count == 1, + case .zap(let config) = channels[0] { + return .payment(type: .zap(transactionInformation: response, + config: config)) + } + return .channelSelection(transactionInformation: response, supportedChannels: channels) } @@ -107,6 +124,8 @@ extension ChargeViewModel { static var supportedMobileMoneyProviders: Set? = [ "MPESA", "ATL_KE", "MTN", "ATL", "VOD", "WAVE_CI", "ORANGE_CI", "MTN_CI" ] + + static var promotedSupportedBankCodes: Set = ["00zap"] } extension ChargeViewModel: ChargeContainer { diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index 5591579..b82271c 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -61,6 +61,9 @@ class ChannelSelectionViewModel: ObservableObject { case .bankTransfer(let config): state = .payment(type: .bankTransfer(transactionInformation: self.information, config: config)) + case .zap(let config): + state = .payment(type: .zap(transactionInformation: self.information, + config: config)) } } } @@ -70,12 +73,18 @@ struct ChannelView: View { let channelTitle: String let image: Image + private let imageSlotSize: CGFloat = 20 + var body: some View { HStack(spacing: .singlePadding) { VStack(alignment: .leading, spacing: .singlePadding) { - image.frame(width: 20, height: 20) + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSlotSize, height: imageSlotSize) + Text(channelTitle) .font(.body14M) .foregroundColor(.navy02) diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index c070221..48b6d8a 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -6,4 +6,6 @@ enum ChargePaymentType: Equatable { provider: MobileMoneyChannel) case bankTransfer(transactionInformation: VerifyAccessCode, config: BankTransferConfig) + case zap(transactionInformation: VerifyAccessCode, + config: ZapConfig) } diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index 66b6d30..c35e443 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -5,6 +5,7 @@ enum SupportedChannel: Equatable, Identifiable { case card case mobileMoney(MobileMoneyChannel) case bankTransfer(BankTransferConfig) + case zap(ZapConfig) var id: String { switch self { @@ -14,6 +15,8 @@ enum SupportedChannel: Equatable, Identifiable { return "mobile_money.\(channel.key)" case .bankTransfer: return "bank_transfer" + case .zap: + return "zap" } } @@ -25,6 +28,8 @@ enum SupportedChannel: Equatable, Identifiable { return channel.value case .bankTransfer: return "Transfer" + case .zap: + return "Zap" } } @@ -36,6 +41,8 @@ enum SupportedChannel: Equatable, Identifiable { return Self.image(forMobileMoneyKey: channel.key) case .bankTransfer: return Image(systemName: "building.columns") + case .zap: + return Image("zapSingleLogo", bundle: .current) } } diff --git a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift index 9774c21..9672578 100644 --- a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift +++ b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift @@ -2,6 +2,8 @@ import Foundation import PaystackCore struct VerifyAccessCode: Equatable { + + var email: String = "" var amount: Decimal var currency: String var accessCode: String @@ -14,6 +16,8 @@ struct VerifyAccessCode: Equatable { var channelOptions: PaystackUI.ChannelOptions? var merchantChannelSettings: MerchantChannelSettings? + var supportedBanks: [SupportedBank]? + var amountCurrency: AmountCurrency { AmountCurrency(amount: amount, currency: currency) } @@ -22,7 +26,8 @@ struct VerifyAccessCode: Equatable { extension VerifyAccessCode { static func from(_ response: VerifyAccessCodeResponse) -> Self { - VerifyAccessCode(amount: response.data.amount, + VerifyAccessCode(email: response.data.email, + amount: response.data.amount, currency: response.data.currency, accessCode: response.data.accessCode, paymentChannels: response.data.channels.filter { $0 != .unsupportedChannel }, @@ -32,20 +37,23 @@ extension VerifyAccessCode { reference: response.data.reference, transactionId: response.data.id, channelOptions: PaystackUI.ChannelOptions.from(response.data.channelOptions), - merchantChannelSettings: response.data.merchantChannelSettings) + merchantChannelSettings: response.data.merchantChannelSettings, + supportedBanks: response.data.supportedBanks) } } extension VerifyAccessCode { static var example: Self { - .init(amount: 10000, + .init(email: "test@email.com", + amount: 10000, currency: "USD", accessCode: "test_access", paymentChannels: [.card], domain: .test, merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", - reference: "test_reference", channelOptions: PaystackUI.ChannelOptions.example) + reference: "test_reference", + channelOptions: PaystackUI.ChannelOptions.example) } } diff --git a/Sources/PaystackUI/Charge/Zap/Components/QRCodeImage.swift b/Sources/PaystackUI/Charge/Zap/Components/QRCodeImage.swift new file mode 100644 index 0000000..b3be795 --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Components/QRCodeImage.swift @@ -0,0 +1,89 @@ +import SwiftUI +import PaystackCore +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14.0, *) +struct QRCodeImage: View { + + let url: URL + + #if canImport(UIKit) + @State private var image: UIImage? + #endif + @State private var loadFailed = false + + var body: some View { + ZStack { + #if canImport(UIKit) + if let image = image { + Image(uiImage: image) + .resizable() + .interpolation(.none) + .scaledToFit() + .transition(.opacity) + } else if loadFailed { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.warning02) + } else { + ShimmerPlaceholder() + } + #else + ShimmerPlaceholder() + #endif + } + .task { await load() } + } + + private func load() async { + #if canImport(UIKit) + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let loaded = UIImage(data: data) { + await MainActor.run { + withAnimation(.easeInOut(duration: 0.25)) { + self.image = loaded + } + } + } else { + await MainActor.run { self.loadFailed = true } + } + } catch { + PaystackCore.Logger.error("Zap QR load failed: %@", + arguments: error.localizedDescription) + await MainActor.run { self.loadFailed = true } + } + #endif + } +} + +@available(iOS 14.0, *) +struct ShimmerPlaceholder: View { + + @State private var phase: CGFloat = -1.0 + + var body: some View { + GeometryReader { geo in + ZStack { + Color.gray01 + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.white.opacity(0.0), location: 0.0), + .init(color: Color.white.opacity(0.5), location: 0.5), + .init(color: Color.white.opacity(0.0), location: 1.0) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing) + .frame(width: geo.size.width * 1.5) + .offset(x: phase * geo.size.width) + } + .cornerRadius(.cornerRadius) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1.0 + } + } + } + } +} diff --git a/Sources/PaystackUI/Charge/Zap/Models/ZapConfig.swift b/Sources/PaystackUI/Charge/Zap/Models/ZapConfig.swift new file mode 100644 index 0000000..6d07614 --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Models/ZapConfig.swift @@ -0,0 +1,8 @@ +import Foundation +import PaystackCore + +struct ZapConfig: Equatable { + let supportedBankId: Int + let transactionId: Int + let walletEmail: String +} diff --git a/Sources/PaystackUI/Charge/Zap/Models/ZapDetails.swift b/Sources/PaystackUI/Charge/Zap/Models/ZapDetails.swift new file mode 100644 index 0000000..761fb1a --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Models/ZapDetails.swift @@ -0,0 +1,35 @@ +import Foundation +import PaystackCore + +struct ZapDetails: Equatable { + let qrImageURL: URL + let paymentURL: URL + let pusherChannel: String + let expiresAt: Date +} + +extension ZapDetails { + + static func from(_ response: ZapMandateResponse, + mandateWindowSeconds: Int) -> ZapDetails? { + guard let qr = URL(string: response.qrImage), + let payment = URL(string: response.paymentUrl) else { + return nil + } + return ZapDetails( + qrImageURL: qr, + paymentURL: payment, + pusherChannel: response.pusherChannel, + expiresAt: Date().addingTimeInterval(TimeInterval(mandateWindowSeconds))) + } +} + +extension ZapDetails { + static var example: ZapDetails { + ZapDetails( + qrImageURL: URL(string: "https://example.com/qr.png")!, + paymentURL: URL(string: "https://joinzap.com/app/merchant-payment/test")!, + pusherChannel: "DBMAN_6222375579", + expiresAt: Date().addingTimeInterval(5 * 60)) + } +} diff --git a/Sources/PaystackUI/Charge/Zap/Models/ZapState.swift b/Sources/PaystackUI/Charge/Zap/Models/ZapState.swift new file mode 100644 index 0000000..4a564b1 --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Models/ZapState.swift @@ -0,0 +1,14 @@ +import Foundation + +enum ZapState: Equatable { + + case loading(message: String? = nil) + + case awaitingPayment(ZapDetails) + + case sessionExpired + + case error(ChargeError) + + case fatalError(error: ChargeError) +} diff --git a/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift new file mode 100644 index 0000000..d38c2fa --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift @@ -0,0 +1,37 @@ +import Foundation +import PaystackCore + +protocol ZapRepository { + + func initiateZapMandate(supportedBankId: Int, + transactionId: Int, + walletEmail: String) async throws -> ZapMandateResponse + + func listenForZapResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate +} + +struct ZapRepositoryImplementation: ZapRepository { + + let paystack: Paystack + + init() { + self.paystack = PaystackContainer.instance.retrieve() + } + + func initiateZapMandate(supportedBankId: Int, + transactionId: Int, + walletEmail: String) async throws -> ZapMandateResponse { + let request = ZapMandateRequest(id: supportedBankId, + transactionId: transactionId, + walletId: walletEmail) + return try await paystack.initiateZapMandate(request).async() + } + + func listenForZapResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate { + let response = try await paystack + .listenForZapResponse(onChannel: channelName).async() + return BankTransferTransactionUpdate.from(response) + } +} diff --git a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift new file mode 100644 index 0000000..28d6383 --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift @@ -0,0 +1,157 @@ +import Foundation +import PaystackCore + +class ZapViewModel: ObservableObject { + + static var mandateWindowSeconds: Int = 5 * 60 + public static var showsOpenZapButton = true + + static var failedFallbackMessage = "Something went wrong" + static var sessionExpiredCopy = + "Your Zap payment session has timed out. Tap below to try again." + + let chargeContainer: ChargeContainer + let repository: ZapRepository + let transactionDetails: VerifyAccessCode + let config: ZapConfig + + @Published + var state: ZapState = .loading() + + @Published + var remainingSeconds: Int = ZapViewModel.mandateWindowSeconds + + private var mandateTimerTask: Task? + private var pusherTask: Task? + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: ZapConfig, + repository: ZapRepository = ZapRepositoryImplementation()) { + self.chargeContainer = chargeContainer + self.transactionDetails = transactionDetails + self.config = config + self.repository = repository + } + + deinit { + mandateTimerTask?.cancel() + pusherTask?.cancel() + } + + @MainActor + func initiateMandate() async { + state = .loading(message: "Setting up your Zap payment") + do { + let response = try await repository.initiateZapMandate( + supportedBankId: config.supportedBankId, + transactionId: config.transactionId, + walletEmail: config.walletEmail) + guard let details = ZapDetails.from(response, + mandateWindowSeconds: Self.mandateWindowSeconds) else { + state = .error(ChargeError( + message: "Zap mandate response missing or malformed URLs")) + return + } + state = .awaitingPayment(details) + startMandateCountdown() + startListeningForPusher(on: details) + } catch { + displayTransactionError(ChargeError(error: error)) + } + } + + @MainActor + func retryAfterExpiry() async { + remainingSeconds = Self.mandateWindowSeconds + await initiateMandate() + } + + @MainActor + func userTappedChangePaymentMethod() { + cancelTimers() + chargeContainer.restartFromChannelSelection() + } + + private func startMandateCountdown() { + mandateTimerTask?.cancel() + let window = Self.mandateWindowSeconds + remainingSeconds = window + guard window > 0 else { return } + + mandateTimerTask = Task { [weak self] in + for _ in 0.. Void + + let showsOpenZapButton: Bool + + private var formattedRemaining: String { + let minutes = max(0, remainingSeconds) / 60 + let seconds = max(0, remainingSeconds) % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private var countdownValueColor: Color { + remainingSeconds <= 60 ? .warning02 : .stackGreen + } + + var body: some View { + ScrollView { + VStack(spacing: .triplePadding) { + + zapHeader + + Text("Open Zap or scan this QR code to complete this payment") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + qrCodeBlock + + expiresInRow + + actionButtons + } + .padding(.doublePadding) + } + } + + // MARK: - Subviews + + private var zapHeader: some View { + + Image("zapLogo", bundle: .current) + .resizable() + .scaledToFit() + .frame(height: 32) + } + + private var qrCodeBlock: some View { + QRCodeImage(url: details.qrImageURL) + .frame(width: 220, height: 220) + .padding(.singlePadding) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: .cornerRadius) + .stroke(Color.navy05, lineWidth: 1)) + } + + private var expiresInRow: some View { + HStack(spacing: 4) { + Text("Expires in") + .font(.body14M) + .foregroundColor(.navy03) + Text(formattedRemaining) + .font(.body14M) + .foregroundColor(countdownValueColor) + .animation(.easeInOut(duration: 0.2), value: countdownValueColor) + } + } + + private var actionButtons: some View { + VStack(spacing: .singlePadding) { + if showsOpenZapButton { + Button("Open Zap", action: openZap) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + } + + Button("Change payment method", action: onChangePaymentMethod) + .buttonStyle(SecondaryButtonStyle()) + } + } + + // MARK: - Actions + + private func openZap() { + #if canImport(UIKit) + UIApplication.shared.open(details.paymentURL, + options: [:], completionHandler: nil) + #endif + } +} + +@available(iOS 14.0, *) +struct ZapPaymentView_Previews: PreviewProvider { + static var previews: some View { + Group { + ZapPaymentView(details: .example, + remainingSeconds: 254, + onChangePaymentMethod: {}, + showsOpenZapButton: true) + .previewDisplayName("Default — Open Zap + Change payment method") + + ZapPaymentView(details: .example, + remainingSeconds: 254, + onChangePaymentMethod: {}, + showsOpenZapButton: false) + .previewDisplayName("QR-only (terminal mode)") + + ZapPaymentView(details: .example, + remainingSeconds: 42, + onChangePaymentMethod: {}, + showsOpenZapButton: true) + .previewDisplayName("Final 60s — countdown warning colour") + } + } +} diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapSessionExpiredView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapSessionExpiredView.swift new file mode 100644 index 0000000..ec247ad --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Views/ZapSessionExpiredView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct ZapSessionExpiredView: View { + + let message: String + let onTryAgain: () async -> Void + + var body: some View { + VStack(spacing: .triplePadding) { + + Image.errorIcon + + Text("Session expired") + .font(.heading2) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + Text(message) + .font(.body14R) + .foregroundColor(.navy02) + .multilineTextAlignment(.center) + .padding(.horizontal, .singlePadding) + + Button("Try again") { + Task { await onTryAgain() } + } + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + } + .padding(.doublePadding) + } +} + +@available(iOS 14.0, *) +struct ZapSessionExpiredView_Previews: PreviewProvider { + static var previews: some View { + ZapSessionExpiredView( + message: ZapViewModel.sessionExpiredCopy, + onTryAgain: {}) + } +} diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift new file mode 100644 index 0000000..4b69c5a --- /dev/null +++ b/Sources/PaystackUI/Charge/Zap/Views/ZapView.swift @@ -0,0 +1,47 @@ +import SwiftUI + + +@available(iOS 14.0, *) +struct ZapView: View { + + @StateObject + var viewModel: ZapViewModel + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: ZapConfig) { + self._viewModel = StateObject(wrappedValue: ZapViewModel( + chargeContainer: chargeContainer, + transactionDetails: transactionDetails, + config: config)) + } + + var body: some View { + VStack(spacing: 0) { + switch viewModel.state { + case .loading(let message): + LoadingView(message: message) + case .awaitingPayment(let details): + ZapPaymentView( + details: details, + remainingSeconds: viewModel.remainingSeconds, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod, + showsOpenZapButton: ZapViewModel.showsOpenZapButton) + case .sessionExpired: + ZapSessionExpiredView( + message: ZapViewModel.sessionExpiredCopy, + onTryAgain: { await viewModel.retryAfterExpiry() }) + case .error(let error): + ErrorView(message: error.message, + buttonText: "Try again", + buttonAction: { Task { await viewModel.initiateMandate() } }) + case .fatalError(let error): + ErrorView(message: error.message, + automaticallyDismissWith: .init( + error: error, + transactionReference: viewModel.transactionDetails.reference)) + } + } + .task(viewModel.initiateMandate) + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/Contents.json new file mode 100644 index 0000000..ebb6773 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "full_zap.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/zapLogo.imageset/full_zap.png b/Sources/PaystackUI/Images/Images.xcassets/zapLogo.imageset/full_zap.png new file mode 100644 index 0000000000000000000000000000000000000000..debc71bf81514c64bee0997276acd70ccfff4fb3 GIT binary patch literal 11514 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 V8u|-~#(%5L>Z&>_wMy0@{{!#&54ivU literal 0 HcmV?d00001 diff --git a/Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/Contents.json new file mode 100644 index 0000000..6a084bc --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_zap.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/zapSingleLogo.imageset/logo_zap.png b/Sources/PaystackUI/Images/Images.xcassets/zapSingleLogo.imageset/logo_zap.png new file mode 100644 index 0000000000000000000000000000000000000000..0e02a99074e55edac6a3db3131ce5db672657608 GIT binary patch literal 3063 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 FV1kQ&!k_>E literal 0 HcmV?d00001 diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 2fb35c1..18dc927 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -32,7 +32,8 @@ final class ChargeRepositoryImplementationTests: PSTestCase { bankTransfer: ["wema-bank", "titan-paystack", "paystack-mfb"]) let expectedMerchantSettings = MerchantChannelSettings( bankTransfer: BankTransferMerchantSettings(fulfilLateNotification: true)) - let expectedResult = VerifyAccessCode(amount: 10000, + let expectedResult = VerifyAccessCode(email: "test@email.com", + amount: 10000, currency: "NGN", accessCode: "Access_Code_Test", paymentChannels: [.card, .qr, .ussd, .mobileMoney], @@ -41,7 +42,13 @@ final class ChargeRepositoryImplementationTests: PSTestCase { publicEncryptionKey: "test_encryption_key", reference: "203520101", channelOptions: expectedChannelOptions, - merchantChannelSettings: expectedMerchantSettings) + merchantChannelSettings: expectedMerchantSettings, + supportedBanks: [ + SupportedBank(id: 870, code: "00zap", + name: "Zap by Paystack", slug: "zap"), + SupportedBank(id: 871, code: "044", + name: "Access Bank", slug: "access-bank") + ]) XCTAssertEqual(result, expectedResult) } diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 55bbe24..16fb8c7 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -263,6 +263,113 @@ final class ChargeViewModelTests: PSTestCase { .error(.init(message: expectedMessage))) } + func testAutoRoutesToZapWhenItIsTheOnlyChannel() async { + let response = VerifyAccessCode.with( + channels: [.bank], + transactionId: 6222375579, + supportedBanks: [.zapFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = ZapConfig(supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .zap(transactionInformation: response, + config: expectedConfig))) + } + + func testZapDoesNotPromoteWhenBankChannelAbsent() async { + let response = VerifyAccessCode.with( + channels: [.card], + transactionId: 6222375579, + supportedBanks: [.zapFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testZapDoesNotPromoteWhen00zapCodeMissingFromSupportedBanks() async { + let response = VerifyAccessCode.with( + channels: [.bank, .card], + transactionId: 6222375579, + supportedBanks: [.accessBankFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testZapDoesNotPromoteWhenSupportedBanksIsNil() async { + let response = VerifyAccessCode.with( + channels: [.bank, .card], + transactionId: 6222375579, + supportedBanks: nil) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testZapDoesNotPromoteWhenTransactionIdMissing() async { + let response = VerifyAccessCode.with( + channels: [.bank], + transactionId: nil, + supportedBanks: [.zapFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedMessage = "No supported payment methods. " + + "Please reach out to your merchant for further information" + XCTAssertEqual(serviceUnderTest.transactionState, + .error(.init(message: expectedMessage))) + } + + func testShowsChannelSelectionWhenZapAndCardBothSupported() async { + let response = VerifyAccessCode.with( + channels: [.bank, .card], + transactionId: 6222375579, + supportedBanks: [.zapFixture, .accessBankFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = ZapConfig(supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .zap(expectedConfig)])) + } + + func testZapConfigCarriesEmailFromVerifyAccessCode() async { + let response = VerifyAccessCode.with( + channels: [.bank], + email: "alice@example.com", + transactionId: 6222375579, + supportedBanks: [.zapFixture]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.zap(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.walletEmail, "alice@example.com") + } else { + XCTFail("Expected .payment(.zap(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + func testRestartFromChannelSelectionRebuildsChannelSelectionFromCachedDetails() async { let response = VerifyAccessCode.with( channels: [.card, .bankTransfer], @@ -384,10 +491,12 @@ private extension MobileMoneyChannel { private extension VerifyAccessCode { static func with(channels: [PaystackCore.Channel], + email: String = "customer@example.com", mobileMoney: [MobileMoneyChannel]? = nil, bankTransferProviders: [String]? = nil, fulfilLateNotification: Bool? = nil, - transactionId: Int? = 1234) -> Self { + transactionId: Int? = 1234, + supportedBanks: [SupportedBank]? = nil) -> Self { let channelOptions: PaystackUI.ChannelOptions? = { if mobileMoney == nil && bankTransferProviders == nil { return nil } return PaystackUI.ChannelOptions(mobileMoney: mobileMoney, @@ -397,7 +506,8 @@ private extension VerifyAccessCode { MerchantChannelSettings( bankTransfer: BankTransferMerchantSettings(fulfilLateNotification: $0)) } - return VerifyAccessCode(amount: 10000, + return VerifyAccessCode(email: email, + amount: 10000, currency: "USD", accessCode: "test_access", paymentChannels: channels, @@ -407,6 +517,14 @@ private extension VerifyAccessCode { reference: "test_reference", transactionId: transactionId, channelOptions: channelOptions, - merchantChannelSettings: settings) + merchantChannelSettings: settings, + supportedBanks: supportedBanks) } } + +extension SupportedBank { + static let zapFixture = SupportedBank(id: 870, code: "00zap", + name: "Zap by Paystack", slug: "zap") + static let accessBankFixture = SupportedBank(id: 871, code: "044", + name: "Access Bank", slug: "access-bank") +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift new file mode 100644 index 0000000..02e42c9 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import PaystackCore +@testable import PaystackUI + +class MockZapRepository: ZapRepository { + + var expectedMandateResponse: ZapMandateResponse? + var expectedErrorResponse: Error? + + var expectedListenForZapResponses: [BankTransferTransactionUpdate] = [] + var expectedListenForZapError: Error? + + var initiateZapMandateSubmitted: (supportedBankId: Int, + transactionId: Int, + walletEmail: String) = (0, 0, "") + private(set) var initiateZapMandateCallCount = 0 + + private(set) var listenForZapResponseCallCount = 0 + private(set) var lastListenedChannel: String? + + func initiateZapMandate(supportedBankId: Int, + transactionId: Int, + walletEmail: String) async throws -> ZapMandateResponse { + initiateZapMandateCallCount += 1 + initiateZapMandateSubmitted = (supportedBankId, transactionId, walletEmail) + guard let response = expectedMandateResponse else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return response + } + + func listenForZapResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate { + listenForZapResponseCallCount += 1 + lastListenedChannel = channelName + + if !expectedListenForZapResponses.isEmpty { + return expectedListenForZapResponses.removeFirst() + } + if let error = expectedListenForZapError { + expectedListenForZapError = nil + throw error + } + throw expectedErrorResponse ?? MockError.stubNotProvided + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift new file mode 100644 index 0000000..a3ee29c --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift @@ -0,0 +1,319 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class ZapViewModelTests: XCTestCase { + + var serviceUnderTest: ZapViewModel! + var mockChargeContainer: MockChargeContainer! + var mockRepository: MockZapRepository! + + override func setUpWithError() throws { + try super.setUpWithError() + mockChargeContainer = MockChargeContainer() + mockRepository = MockZapRepository() + // Stub the countdown short so tests don't wait — individual + // tests that exercise the countdown override this. + ZapViewModel.mandateWindowSeconds = 0 + serviceUnderTest = ZapViewModel( + chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + } + + override func tearDownWithError() throws { + ZapViewModel.mandateWindowSeconds = 5 * 60 + try super.tearDownWithError() + } + + // MARK: - Initial state + + func testInitialStateIsLoading() { + XCTAssertEqual(serviceUnderTest.state, .loading()) + } + + // MARK: - initiateMandate + + func testInitiateMandateOnSuccessSetsStateToAwaitingPayment() async { + mockRepository.expectedMandateResponse = .example + + await serviceUnderTest.initiateMandate() + + if case .awaitingPayment(let details) = serviceUnderTest.state { + XCTAssertEqual(details.pusherChannel, "DBMAN_6222375579") + } else { + XCTFail("Expected .awaitingPayment, got \(serviceUnderTest.state)") + } + } + + func testInitiateMandateForwardsConfigFieldsToRepository() async { + mockRepository.expectedMandateResponse = .example + + await serviceUnderTest.initiateMandate() + + XCTAssertEqual(mockRepository.initiateZapMandateCallCount, 1) + XCTAssertEqual(mockRepository.initiateZapMandateSubmitted.supportedBankId, 870) + XCTAssertEqual(mockRepository.initiateZapMandateSubmitted.transactionId, 6222375579) + XCTAssertEqual(mockRepository.initiateZapMandateSubmitted.walletEmail, + "customer@example.com") + } + + func testInitiateMandateOnApiErrorSetsStateToError() async { + let expectedError = PaystackError.response(code: 500, message: "Boom") + mockRepository.expectedErrorResponse = expectedError + + await serviceUnderTest.initiateMandate() + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(error: expectedError))) + } + + func testInitiateMandateWithMalformedUrlsSetsStateToError() async { + // QR / payment URLs that don't parse into a `URL` instance + // surface as the SDK's generic error path. + mockRepository.expectedMandateResponse = ZapMandateResponse( + status: "pending", + message: "OK", + pusherChannel: "DBMAN_test", + paymentUrl: "", + qrImage: "") + + await serviceUnderTest.initiateMandate() + + if case .error(let error) = serviceUnderTest.state { + XCTAssertTrue(error.message.contains("malformed") + || error.message.contains("URLs")) + } else { + XCTFail("Expected .error, got \(serviceUnderTest.state)") + } + } + + func testInitiateMandateSetsLoadingStateBeforeAwaitingPayment() async { + mockRepository.expectedMandateResponse = .example + + XCTAssertEqual(serviceUnderTest.state, .loading()) + + await serviceUnderTest.initiateMandate() + + if case .awaitingPayment = serviceUnderTest.state { + // ok + } else { + XCTFail("Expected .awaitingPayment, got \(serviceUnderTest.state)") + } + } + + // MARK: - Countdown + sessionExpired + + func testRemainingSecondsInitialisesToMandateWindow() { + ZapViewModel.mandateWindowSeconds = 300 + let vm = ZapViewModel(chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + XCTAssertEqual(vm.remainingSeconds, 300) + } + + func testCountdownTicksRemainingSecondsDown() async throws { + ZapViewModel.mandateWindowSeconds = 5 + serviceUnderTest = ZapViewModel(chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + mockRepository.expectedMandateResponse = .example + await serviceUnderTest.initiateMandate() + + try await Task.sleep(nanoseconds: 1_300_000_000) + + XCTAssertLessThanOrEqual(serviceUnderTest.remainingSeconds, 4) + XCTAssertGreaterThanOrEqual(serviceUnderTest.remainingSeconds, 3) + } + + func testCountdownExpiryTransitionsToSessionExpired() async throws { + ZapViewModel.mandateWindowSeconds = 1 + serviceUnderTest = ZapViewModel(chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + mockRepository.expectedMandateResponse = .example + await serviceUnderTest.initiateMandate() + + try await Task.sleep(nanoseconds: 1_400_000_000) + + XCTAssertEqual(serviceUnderTest.state, .sessionExpired) + } + + func testRetryAfterExpiryReprovisionsMandateAndResetsCountdown() async { + ZapViewModel.mandateWindowSeconds = 60 + serviceUnderTest = ZapViewModel(chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + // Force state into sessionExpired without waiting. + serviceUnderTest.state = .sessionExpired + mockRepository.expectedMandateResponse = .example + + await serviceUnderTest.retryAfterExpiry() + + XCTAssertEqual(mockRepository.initiateZapMandateCallCount, 1) + if case .awaitingPayment = serviceUnderTest.state { + // ok + } else { + XCTFail("Expected .awaitingPayment after retry, got \(serviceUnderTest.state)") + } + XCTAssertGreaterThan(serviceUnderTest.remainingSeconds, 50) + } + + // MARK: - User actions + + @MainActor + func testUserTappedChangePaymentMethodCallsRestartFromChannelSelection() { + serviceUnderTest.userTappedChangePaymentMethod() + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + // MARK: - displayTransactionError + + func testDisplayTransactionErrorSetsStateToErrorWithGivenError() async { + let error = ChargeError(message: "Something broke") + await serviceUnderTest.displayTransactionError(error) + XCTAssertEqual(serviceUnderTest.state, .error(error)) + } + + // MARK: - processTransactionUpdate — success + failed are the only + // statuses Zap emits ; everything else is logged + state unchanged. + + func testProcessUpdateWithSuccessCallsContainerProcessSuccessfulTransaction() async { + await serviceUnderTest.processTransactionUpdate( + .init(status: .success, message: nil, reference: nil, transactionId: nil)) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testProcessUpdateWithFailedSetsStateToErrorWithApiMessage() async { + mockRepository.expectedMandateResponse = .example + await serviceUnderTest.initiateMandate() + + await serviceUnderTest.processTransactionUpdate( + .init(status: .failed, + message: "Bank declined", + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testProcessUpdateWithFailedFallsBackToDefaultMessageWhenNil() async { + await serviceUnderTest.processTransactionUpdate( + .init(status: .failed, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: ZapViewModel.failedFallbackMessage))) + } + + /// Zap doesn't emit any of the PWT-shared status cases beyond + /// `success` / `failed` ; if one ever arrives (forward compat with + /// shared types) the SDK logs + leaves state unchanged. Regression + /// guard against accidentally wiring a state change for these cases + /// in the future. + func testProcessUpdateWithUnexpectedStatusesDoesNotChangeState() async { + let unexpectedStatuses: [BankTransferStatus] = [ + .creditRequestPending, + .creditRequestReceived, + .creditRequestRejected, + .incorrectAmountSent, + .pending, + .requery, + .unknown("brand-new") + ] + mockRepository.expectedMandateResponse = .example + await serviceUnderTest.initiateMandate() + + let stateBefore = serviceUnderTest.state + + for status in unexpectedStatuses { + await serviceUnderTest.processTransactionUpdate( + .init(status: status, message: nil, + reference: nil, transactionId: nil)) + XCTAssertEqual(serviceUnderTest.state, stateBefore, + "Status \(status) unexpectedly changed state") + } + } + + // MARK: - Listen loop + + func testProvisioningStartsListenLoopOnReturnedChannel() async { + mockRepository.expectedMandateResponse = .example + + await serviceUnderTest.initiateMandate() + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertGreaterThanOrEqual(mockRepository.listenForZapResponseCallCount, 1) + XCTAssertEqual(mockRepository.lastListenedChannel, "DBMAN_6222375579") + } + + func testListenLoopExitsOnSuccessAndRoutesToContainer() async { + mockRepository.expectedMandateResponse = .example + mockRepository.expectedListenForZapResponses = [ + .init(status: .success, message: nil, + reference: nil, transactionId: nil) + ] + + let expectation = expectation(description: "container receives success") + mockChargeContainer.onProcessSuccessfulTransaction = { expectation.fulfill() } + + await serviceUnderTest.initiateMandate() + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertEqual(mockRepository.listenForZapResponseCallCount, 1) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testListenLoopExitsOnFailedStatusToErrorState() async { + mockRepository.expectedMandateResponse = .example + mockRepository.expectedListenForZapResponses = [ + .init(status: .failed, message: "Bank declined", + reference: nil, transactionId: nil) + ] + + await serviceUnderTest.initiateMandate() + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(mockRepository.listenForZapResponseCallCount, 1) + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testListenLoopExitsOnRepositoryErrorWithoutCrashing() async { + mockRepository.expectedMandateResponse = .example + mockRepository.expectedListenForZapError = PaystackError.technical + + await serviceUnderTest.initiateMandate() + try? await Task.sleep(nanoseconds: 200_000_000) + + // State stays at awaitingPayment — listen loop died but + // initiateMandate still set us up correctly. + if case .awaitingPayment = serviceUnderTest.state { + // ok + } else { + XCTFail("Expected .awaitingPayment, got \(serviceUnderTest.state)") + } + } +} + +// MARK: - Fixtures + +private extension ZapConfig { + static let example = ZapConfig(supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") +} + +private extension ZapMandateResponse { + static let example = ZapMandateResponse( + status: "pending", + message: "Transaction Initiated", + pusherChannel: "DBMAN_6222375579", + paymentUrl: "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu", + qrImage: "https://example.com/qr.png") +}