From 30fed67228c604d474e07e019db8fcb5500043b9 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 23 Jul 2026 08:20:47 +0200 Subject: [PATCH] Add Capitec Pay and QR (Scan to Pay) charge flows Introduce two new payment methods end to end, following the existing Views/Viewmodels/Models/Repository layout under PaystackUI/Charge. Core (PaystackSDK): - CapitecPay: authenticate endpoint via CapitecPayService, with CapitecPayAuthenticateRequest/Response models - QR: generate endpoint via QRService, with QRGenerateRequest/Response models - Add Capitec/QR cases to Channel UI (PaystackUI): - Capitec Pay flow: identifier entry, awaiting-approval, info banner, view model, repository, and South African ID/phone validators - QR flow: display view, variant handling, view model, repository, and QRChannelDirectory - Wire both into ChargeView/ChargeViewModel, channel selection, and supported-channel/payment-type models - Add reusable CopiedToast component Tests: - API, repository, view model, and validator coverage for both flows, plus QRChannelDirectory and updated Charge view model/repository tests --- .../PaystackSDK/API/Charge/CapitecPay.swift | 61 ++++ .../API/Charge/CapitecPayService.swift | 27 ++ Sources/PaystackSDK/API/Charge/QR.swift | 52 +++ .../PaystackSDK/API/Charge/QRService.swift | 19 + .../CapitecPayAuthenticateRequest.swift | 13 + .../CapitecPayAuthenticateResponse.swift | 33 ++ .../Core/Models/Models/Channel.swift | 1 + .../Models/Models/QRGenerateRequest.swift | 15 + .../Models/Models/QRGenerateResponse.swift | 33 ++ .../CapitecPay/Models/CapitecPayConfig.swift | 7 + .../CapitecPay/Models/CapitecPayDetails.swift | 27 ++ .../Models/CapitecPayIdentifier.swift | 36 ++ .../CapitecPay/Models/CapitecPayState.swift | 10 + .../Repository/CapitecPayRepository.swift | 56 +++ .../Validation/SouthAfricanIDValidator.swift | 65 ++++ .../SouthAfricanPhoneValidator.swift | 13 + .../Viewmodels/CapitecPayViewModel.swift | 237 +++++++++++++ .../CapitecPayAwaitingApprovalView.swift | 137 ++++++++ .../Views/CapitecPayIdentifierEntryView.swift | 142 ++++++++ .../Views/CapitecPayInfoBanner.swift | 22 ++ .../CapitecPay/Views/CapitecPayView.swift | 52 +++ Sources/PaystackUI/Charge/ChargeView.swift | 8 + .../PaystackUI/Charge/ChargeViewModel.swift | 38 ++ .../Views/ChannelSelectionView.swift | 6 + .../Charge/Models/ChannelOptions.swift | 4 +- .../Charge/Models/ChargePaymentType.swift | 4 + .../Charge/Models/SupportedChannel.swift | 21 ++ .../Charge/QR/Models/QRConfig.swift | 19 + .../Charge/QR/Models/QRDetails.swift | 37 ++ .../PaystackUI/Charge/QR/Models/QRState.swift | 9 + .../Charge/QR/Models/QRVariant.swift | 42 +++ .../Charge/QR/Repository/QRRepository.swift | 51 +++ .../Charge/QR/Viewmodels/QRViewModel.swift | 162 +++++++++ .../Charge/QR/Views/QRDisplayView.swift | 146 ++++++++ .../PaystackUI/Charge/QR/Views/QRView.swift | 47 +++ .../PaystackUI/Components/CopiedToast.swift | 57 +++ .../PaystackUI/Utils/QRChannelDirectory.swift | 22 ++ .../API/Charge/CapitecPayTests.swift | 106 ++++++ .../PaystackSDKTests/API/Charge/QRTests.swift | 82 +++++ .../CapitecPay/CapitecPayViewModelTests.swift | 332 ++++++++++++++++++ .../SouthAfricanValidatorTests.swift | 65 ++++ ...itecPayRepositoryImplementationTests.swift | 91 +++++ .../ChargeRepositoryImplementationTests.swift | 3 +- .../UI/Charge/ChargeViewModelTests.swift | 147 +++++++- .../Mocks/MockCapitecPayRepository.swift | 62 ++++ .../UI/Charge/Mocks/MockQRRepository.swift | 67 ++++ .../Charge/QR/QRChannelDirectoryTests.swift | 58 +++ .../UI/Charge/QR/QRViewModelTests.swift | 243 +++++++++++++ .../QRRepositoryImplementationTests.swift | 95 +++++ 49 files changed, 3078 insertions(+), 4 deletions(-) create mode 100644 Sources/PaystackSDK/API/Charge/CapitecPay.swift create mode 100644 Sources/PaystackSDK/API/Charge/CapitecPayService.swift create mode 100644 Sources/PaystackSDK/API/Charge/QR.swift create mode 100644 Sources/PaystackSDK/API/Charge/QRService.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateRequest.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateResponse.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/QRGenerateResponse.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayConfig.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayDetails.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayIdentifier.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayState.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanIDValidator.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanPhoneValidator.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift create mode 100644 Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayView.swift create mode 100644 Sources/PaystackUI/Charge/QR/Models/QRConfig.swift create mode 100644 Sources/PaystackUI/Charge/QR/Models/QRDetails.swift create mode 100644 Sources/PaystackUI/Charge/QR/Models/QRState.swift create mode 100644 Sources/PaystackUI/Charge/QR/Models/QRVariant.swift create mode 100644 Sources/PaystackUI/Charge/QR/Repository/QRRepository.swift create mode 100644 Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift create mode 100644 Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift create mode 100644 Sources/PaystackUI/Charge/QR/Views/QRView.swift create mode 100644 Sources/PaystackUI/Components/CopiedToast.swift create mode 100644 Sources/PaystackUI/Utils/QRChannelDirectory.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/QRTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/CapitecPay/SouthAfricanValidatorTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/Mocks/MockQRRepository.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/QR/QRChannelDirectoryTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift create mode 100644 Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift diff --git a/Sources/PaystackSDK/API/Charge/CapitecPay.swift b/Sources/PaystackSDK/API/Charge/CapitecPay.swift new file mode 100644 index 0000000..faec052 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/CapitecPay.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Public Capitec Pay surface. Used by the UI module to authenticate a +/// Capitec Pay transaction, poll for its status via the Capitec-specific +/// requery endpoint, and subscribe for Pusher events. Can also be called +/// directly by integrators driving their own UI on top of `PaystackCore`. +public extension Paystack { + + private var capitecPayService: CapitecPayService { + return CapitecPayServiceImplementation(config: config) + } + + /// Authenticates a Capitec Pay transaction against + /// `POST /capitec-pay/authenticate`. The `clientdata` field on + /// ``CapitecPayAuthenticateRequest`` must already be RSA-encrypted + /// using the merchant's public encryption key from + /// ``VerifyAccessCode``. + /// + /// - Parameter request: The authenticate payload — pre-encrypted + /// client data, transaction id, and device fingerprint. + /// - Returns: A ``Service`` carrying a ``CapitecPayAuthenticateResponse`` + /// with the `timeToLive` window the SDK counts down against. + func authenticateCapitecPay(_ request: CapitecPayAuthenticateRequest) + -> Service { + return capitecPayService.postAuthenticate(request) + } + + /// Polls the Capitec Pay requery endpoint + /// (`POST /capitec-pay/requery/{transactionReference}`) for the + /// current transaction status. The response follows the standard + /// charge shape — same decoding path as ``checkPendingCharge(forAccessCode:)``. + /// + /// - Parameter transactionReference: The `reference` returned from + /// `verify_access_code`. + /// - Returns: A ``Service`` carrying a ``ChargeResponse``. + func requeryCapitecPay(transactionReference: String) + -> Service { + return capitecPayService.postRequery(transactionReference: transactionReference) + } + + /// Listens for Capitec Pay status updates on the Pusher channel + /// returned by ``authenticateCapitecPay(_:)``. The server publishes + /// only terminal events (`success` / `failed`) on the Capitec Pay + /// channel, so this helper returns the narrow ``Charge3DSResponse`` + /// shape shared with card 3-D Secure and mobile money authorization. + /// + /// The underlying listener is single-shot per the existing + /// `PusherSubscriptionListener` contract — one event resolves the + /// listener. + /// + /// - Parameter channelName: The `CAPITECPAY_{transactionId}` channel + /// for this transaction. + /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// first event the channel emits. + func listenForCapitecPayResponse(onChannel channelName: String) + -> Service { + let subscription: any Subscription = PusherSubscription( + channelName: channelName, eventName: "response") + return Service(subscription) + } +} diff --git a/Sources/PaystackSDK/API/Charge/CapitecPayService.swift b/Sources/PaystackSDK/API/Charge/CapitecPayService.swift new file mode 100644 index 0000000..a7cc1b2 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/CapitecPayService.swift @@ -0,0 +1,27 @@ +import Foundation + +protocol CapitecPayService: PaystackService { + func postAuthenticate(_ request: CapitecPayAuthenticateRequest) + -> Service + func postRequery(transactionReference: String) + -> Service +} + +struct CapitecPayServiceImplementation: CapitecPayService { + + var config: PaystackConfig + + var parentPath: String { "capitec-pay" } + + func postAuthenticate(_ request: CapitecPayAuthenticateRequest) + -> Service { + return post("/authenticate", request) + .asService() + } + + func postRequery(transactionReference: String) + -> Service { + return post("/requery/\(transactionReference)", EmptyRequest()) + .asService() + } +} diff --git a/Sources/PaystackSDK/API/Charge/QR.swift b/Sources/PaystackSDK/API/Charge/QR.swift new file mode 100644 index 0000000..97a4003 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/QR.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Public QR-payment surface. Used by the UI module to generate a hosted +/// QR image for a transaction (Scan to Pay / Snap Scan et al) and to +/// subscribe for Pusher status events. Can also be called directly by +/// integrators driving their own UI on top of `PaystackCore`. +public extension Paystack { + + private var qrService: QRService { + return QRServiceImplementation(config: config) + } + + /// Generates a QR code for a transaction against + /// `POST /offline/qr/generate`. The `channel` field on + /// ``QRGenerateRequest`` is the provider code returned in + /// `VerifyAccessCode.channelOptions.qrCode` (for example + /// `"MPASS_OLTI"` for Ukheshe-served flows in ZA) — the SDK does not + /// hard-code that value so the same call works for future markets + /// that expose different provider codes. + /// + /// - Parameter request: The generate payload — source, transaction + /// reference, and provider channel code. + /// - Returns: A ``Service`` carrying a ``QRGenerateResponse`` whose + /// `data.url` is a signed S3 URL for the QR image and whose + /// `data.channel` is the Pusher channel to subscribe on. + func generateQR(_ request: QRGenerateRequest) + -> Service { + return qrService.postGenerate(request) + } + + /// Listens for QR-payment status updates on the Pusher channel + /// returned by ``generateQR(_:)``. The server publishes only terminal + /// events (`success` / `failed`) on the QR channel, so this helper + /// returns the narrow ``Charge3DSResponse`` shape shared with card + /// 3-D Secure and mobile money authorization. + /// + /// The underlying listener is single-shot per the existing + /// `PusherSubscriptionListener` contract — one event resolves the + /// listener. + /// + /// - Parameter channelName: The `data.channel` value returned by + /// ``generateQR(_:)`` (for example + /// `"api_mpass_olti_qr_51826223921246"`). + /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// first event the channel emits. + func listenForQRResponse(onChannel channelName: String) + -> Service { + let subscription: any Subscription = PusherSubscription( + channelName: channelName, eventName: "response") + return Service(subscription) + } +} diff --git a/Sources/PaystackSDK/API/Charge/QRService.swift b/Sources/PaystackSDK/API/Charge/QRService.swift new file mode 100644 index 0000000..44fcfda --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/QRService.swift @@ -0,0 +1,19 @@ +import Foundation + +protocol QRService: PaystackService { + func postGenerate(_ request: QRGenerateRequest) + -> Service +} + +struct QRServiceImplementation: QRService { + + var config: PaystackConfig + + var parentPath: String { "offline/qr" } + + func postGenerate(_ request: QRGenerateRequest) + -> Service { + return post("/generate", request) + .asService() + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateRequest.swift b/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateRequest.swift new file mode 100644 index 0000000..5a49049 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct CapitecPayAuthenticateRequest: Encodable, Equatable { + public let clientdata: String + public let trans: String + public let device: String + + public init(clientdata: String, trans: String, device: String) { + self.clientdata = clientdata + self.trans = trans + self.device = device + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateResponse.swift b/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateResponse.swift new file mode 100644 index 0000000..170da86 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/CapitecPayAuthenticateResponse.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct CapitecPayAuthenticateResponse: Decodable, Equatable { + public let status: Bool + public let type: String + public let code: String + public let data: CapitecPayAuthenticateData + public let message: String + + public init(status: Bool, + type: String, + code: String, + data: CapitecPayAuthenticateData, + message: String) { + self.status = status + self.type = type + self.code = code + self.data = data + self.message = message + } +} + +public struct CapitecPayAuthenticateData: Decodable, Equatable { + public let status: String + public let timeToLive: Int + public let expiryDate: Date + + public init(status: String, timeToLive: Int, expiryDate: Date) { + self.status = status + self.timeToLive = timeToLive + self.expiryDate = expiryDate + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/Channel.swift b/Sources/PaystackSDK/Core/Models/Models/Channel.swift index efbd007..930a3fe 100644 --- a/Sources/PaystackSDK/Core/Models/Models/Channel.swift +++ b/Sources/PaystackSDK/Core/Models/Models/Channel.swift @@ -14,6 +14,7 @@ public enum Channel: String, Codable { case mobileMoney = "mobile_money" case qr = "qr" case bankTransfer = "bank_transfer" + case capitecPay = "capitec_pay" case unsupportedChannel public init(from decoder: Decoder) throws { diff --git a/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift b/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift new file mode 100644 index 0000000..2b2e7ef --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct QRGenerateRequest: Encodable, Equatable { + public let source: String + public let reference: String + public let channel: String + + public init(source: String = "mobile-pos", + reference: String, + channel: String) { + self.source = source + self.reference = reference + self.channel = channel + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/QRGenerateResponse.swift b/Sources/PaystackSDK/Core/Models/Models/QRGenerateResponse.swift new file mode 100644 index 0000000..84ee191 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/QRGenerateResponse.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct QRGenerateResponse: Decodable, Equatable { + public let status: Bool + public let message: String + public let data: QRGenerateData + + public init(status: Bool, message: String, data: QRGenerateData) { + self.status = status + self.message = message + self.data = data + } +} + +public struct QRGenerateData: Decodable, Equatable { + public let errors: Bool + public let url: String + public let qrCode: String + public let status: String + public let channel: String + + public init(errors: Bool, + url: String, + qrCode: String, + status: String, + channel: String) { + self.errors = errors + self.url = url + self.qrCode = qrCode + self.status = status + self.channel = channel + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayConfig.swift b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayConfig.swift new file mode 100644 index 0000000..d549eb3 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayConfig.swift @@ -0,0 +1,7 @@ +import Foundation + +struct CapitecPayConfig: Equatable { + let transactionId: Int + let transactionReference: String + let publicEncryptionKey: String +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayDetails.swift b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayDetails.swift new file mode 100644 index 0000000..e050d76 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayDetails.swift @@ -0,0 +1,27 @@ +import Foundation +import PaystackCore + +struct CapitecPayDetails: Equatable { + let timeToLive: Int + let expiryDate: Date + let pusherChannel: String +} + +extension CapitecPayDetails { + static func from(_ response: CapitecPayAuthenticateResponse, + transactionId: Int) -> CapitecPayDetails { + CapitecPayDetails( + timeToLive: response.data.timeToLive, + expiryDate: response.data.expiryDate, + pusherChannel: "CAPITECPAY_\(transactionId)") + } +} + +extension CapitecPayDetails { + static var example: CapitecPayDetails { + CapitecPayDetails( + timeToLive: 120, + expiryDate: Date().addingTimeInterval(120), + pusherChannel: "CAPITECPAY_5900549926") + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayIdentifier.swift b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayIdentifier.swift new file mode 100644 index 0000000..98db08a --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayIdentifier.swift @@ -0,0 +1,36 @@ +import Foundation + +enum CapitecPayIdentifier: String, CaseIterable, Equatable, CustomStringConvertible { + case cellphone = "CELLPHONE" + case idNumber = "IDNUMBER" + case accountNumber = "ACCOUNTNUMBER" + + var pickerTitle: String { + switch self { + case .cellphone: return "Cellphone Number" + case .idNumber: return "ID Number" + case .accountNumber: return "Account Number" + } + } + + var description: String { pickerTitle } + + var prompt: String { + switch self { + case .cellphone: + return "Enter the mobile number linked to your Capitec account" + case .idNumber: + return "Enter the ID number linked to your Capitec account" + case .accountNumber: + return "Enter the account number linked to your Capitec account" + } + } + + var placeholder: String { + switch self { + case .cellphone: return "Enter your cellphone number" + case .idNumber: return "Enter your ID number" + case .accountNumber: return "Enter your account number" + } + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayState.swift b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayState.swift new file mode 100644 index 0000000..0924bce --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Models/CapitecPayState.swift @@ -0,0 +1,10 @@ +import Foundation + +enum CapitecPayState: Equatable { + case identifierEntry + case authenticating + case awaitingApproval(CapitecPayDetails) + case requerying(CapitecPayDetails) + case error(ChargeError) + case fatalError(error: ChargeError) +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift new file mode 100644 index 0000000..4a4663d --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift @@ -0,0 +1,56 @@ +import Foundation +import PaystackCore + +protocol CapitecPayRepository { + func authenticate(identifier: CapitecPayIdentifier, + value: String, + transactionId: Int, + deviceId: String, + publicEncryptionKey: String) async throws -> CapitecPayDetails + + func requery(transactionReference: String) async throws -> ChargeCardTransaction + + func listenForCapitecPayResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction +} + +struct CapitecPayRepositoryImplementation: CapitecPayRepository { + + let paystack: Paystack + let cryptography: CryptographyProtocol + + init(cryptography: CryptographyProtocol = Cryptography()) { + self.paystack = PaystackContainer.instance.retrieve() + self.cryptography = cryptography + } + + func authenticate(identifier: CapitecPayIdentifier, + value: String, + transactionId: Int, + deviceId: String, + publicEncryptionKey: String) async throws -> CapitecPayDetails { + + let plaintext = "\(identifier.rawValue)*\(value)" + let clientdata = try cryptography.encryptPKCS1( + text: plaintext, publicKey: publicEncryptionKey) + let request = CapitecPayAuthenticateRequest( + clientdata: clientdata, + trans: "\(transactionId)", + device: deviceId) + let response = try await paystack.authenticateCapitecPay(request).async() + return CapitecPayDetails.from(response, transactionId: transactionId) + } + + func requery(transactionReference: String) async throws -> ChargeCardTransaction { + let response = try await paystack + .requeryCapitecPay(transactionReference: transactionReference).async() + return ChargeCardTransaction.from(response) + } + + func listenForCapitecPayResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction { + let response = try await paystack + .listenForCapitecPayResponse(onChannel: channelName).async() + return ChargeCardTransaction.from(response) + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanIDValidator.swift b/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanIDValidator.swift new file mode 100644 index 0000000..dbbf1bb --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanIDValidator.swift @@ -0,0 +1,65 @@ +import Foundation + +enum SouthAfricanIDValidator { + + static func isValid(_ input: String) -> Bool { + guard input.count == 13, + input.allSatisfy({ $0.isNumber }) else { + return false + } + guard hasValidDatePortion(input) else { + return false + } + return luhnChecksumPasses(input) + } + + private static func hasValidDatePortion(_ input: String) -> Bool { + let yy = String(input.prefix(2)) + let mm = String(input.dropFirst(2).prefix(2)) + let dd = String(input.dropFirst(4).prefix(2)) + + guard let yyInt = Int(yy), + let mmInt = Int(mm), + let ddInt = Int(dd) else { + return false + } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + + let currentYearShort = calendar.component(.year, from: Date()) % 100 + let candidates = yyInt <= currentYearShort ? [2000, 1900] : [1900] + + for century in candidates { + var components = DateComponents() + components.year = century + yyInt + components.month = mmInt + components.day = ddInt + if let date = calendar.date(from: components), + calendar.component(.year, from: date) == century + yyInt, + calendar.component(.month, from: date) == mmInt, + calendar.component(.day, from: date) == ddInt, + date <= Date() { + return true + } + } + return false + } + + private static func luhnChecksumPasses(_ input: String) -> Bool { + let digits = input.compactMap { Int(String($0)) } + guard digits.count == 13 else { return false } + + var sum = 0 + for (index, digit) in digits.enumerated() { + let positionFromRight = digits.count - 1 - index + if positionFromRight % 2 == 1 { + let doubled = digit * 2 + sum += doubled > 9 ? doubled - 9 : doubled + } else { + sum += digit + } + } + return sum % 10 == 0 + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanPhoneValidator.swift b/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanPhoneValidator.swift new file mode 100644 index 0000000..65d8472 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Validation/SouthAfricanPhoneValidator.swift @@ -0,0 +1,13 @@ +import Foundation + +enum SouthAfricanPhoneValidator { + + static func isValid(_ input: String) -> Bool { + let pattern = "^0[6-8][0-9]{8}$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return false + } + let range = NSRange(location: 0, length: input.utf16.count) + return regex.firstMatch(in: input, range: range) != nil + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift new file mode 100644 index 0000000..5cc8ad5 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift @@ -0,0 +1,237 @@ +import Foundation +import PaystackCore +#if canImport(UIKit) +import UIKit +#endif + +class CapitecPayViewModel: ObservableObject { + + static var requeryPollIntervalSeconds: Int = 10 + static var requeryMaxIterations: Int = 18 + + static var failedFallbackMessage = "The transaction could not be confirmed" + static var authenticateFailedMessage = "We couldn't start your Capitec Pay payment" + + let chargeContainer: ChargeContainer + let repository: CapitecPayRepository + let transactionDetails: VerifyAccessCode + let config: CapitecPayConfig + + @Published + var state: CapitecPayState = .identifierEntry + + @Published + var identifier: CapitecPayIdentifier = .cellphone + + @Published + var value: String = "" + + @Published + var remainingSeconds: Int = 0 + + private var approvalCountdownTask: Task? + private var pusherTask: Task? + private var requeryLoopTask: Task? + private var immediateRequeryTask: Task? + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: CapitecPayConfig, + repository: CapitecPayRepository = CapitecPayRepositoryImplementation()) { + self.chargeContainer = chargeContainer + self.transactionDetails = transactionDetails + self.config = config + self.repository = repository + } + + deinit { + approvalCountdownTask?.cancel() + pusherTask?.cancel() + requeryLoopTask?.cancel() + immediateRequeryTask?.cancel() + } + + var isValid: Bool { + switch identifier { + case .cellphone: + return SouthAfricanPhoneValidator.isValid(value) + case .idNumber: + return SouthAfricanIDValidator.isValid(value) + case .accountNumber: + return !value.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + @MainActor + func submitIdentifier() async { + guard isValid else { return } + state = .authenticating + do { + let details = try await repository.authenticate( + identifier: identifier, + value: value, + transactionId: config.transactionId, + //deviceId: deviceFingerprint(), + deviceId: "E403F2353A734C9A871BD0276BF92312", + publicEncryptionKey: config.publicEncryptionKey) + state = .awaitingApproval(details) + remainingSeconds = details.timeToLive + startApprovalCountdown() + startListeningForPusher(on: details) + } catch { + displayTransactionError(ChargeError(error: error)) + } + } + + @MainActor + func userTappedIveApprovedThePayment() { + immediateRequeryTask?.cancel() + immediateRequeryTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await self.repository.requery( + transactionReference: self.transactionDetails.reference) + await self.reactToPollResult(result) + } catch { + Logger.error("Capitec Pay immediate requery failed: %@", + arguments: error.localizedDescription) + } + } + } + + @MainActor + func userTappedChangePaymentMethod() { + cancelAllTasks() + chargeContainer.restartFromChannelSelection() + } + + @MainActor + func displayTransactionError(_ error: ChargeError) { + Logger.error("Displaying Capitec Pay error: %@", + arguments: error.localizedDescription) + cancelAllTasks() + state = .error(error) + } + + private func deviceFingerprint() -> String { + #if canImport(UIKit) + return UIDevice.current.identifierForVendor?.uuidString ?? "" + #else + return "" + #endif + } + + private func startApprovalCountdown() { + approvalCountdownTask?.cancel() + let window = remainingSeconds + guard window > 0 else { return } + approvalCountdownTask = Task { [weak self] in + for _ in 0.. Bool { + switch result.status { + case .success: + cancelAllTasks() + chargeContainer.processSuccessfulTransaction(details: transactionDetails) + return true + case .failed: + cancelAllTasks() + let message = result.message ?? result.displayText ?? Self.failedFallbackMessage + state = .error(ChargeError(message: message)) + return true + default: + return false + } + } + + private func cancelAllTasks() { + approvalCountdownTask?.cancel() + approvalCountdownTask = nil + pusherTask?.cancel() + pusherTask = nil + requeryLoopTask?.cancel() + requeryLoopTask = nil + immediateRequeryTask?.cancel() + immediateRequeryTask = nil + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift new file mode 100644 index 0000000..edd2e96 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift @@ -0,0 +1,137 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14.0, *) +struct CapitecPayAwaitingApprovalView: View { + + let remainingSeconds: Int + let isRequerying: Bool + let onIveApprovedThePayment: () -> Void + let onChangePaymentMethod: () -> Void + + 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) { + + Text("Complete your payment") + .font(.heading2) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + stepsCard + + progressSection + + Button("I've approved the payment", action: onIveApprovedThePayment) + .buttonStyle(SecondaryButtonStyle()) + + Button("Change payment method", action: onChangePaymentMethod) + .foregroundColor(.navy02) + .font(.body14M) + .padding(.top, .singlePadding) + } + .padding(.doublePadding) + } + } + + private var stepsCard: some View { + VStack(alignment: .leading, spacing: .singlePadding) { + step("Open your ", bold: "Capitec app") + step("Tap on ", bold: "Transact", trailing: " in the bottom navigation") + step("Tap on ", bold: "Capitec Pay") + step("Tap on ", bold: "Pay", trailing: " to approve the payment") + } + .padding(.doublePadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray01.opacity(0.4)) + .cornerRadius(.cornerRadius) + } + + @ViewBuilder + private func step(_ prefix: String, bold: String, trailing: String = "") -> some View { + HStack(alignment: .top, spacing: .singlePadding) { + Circle() + .fill(Color.stackGreen) + .frame(width: 6, height: 6) + .padding(.top, 8) + (Text(prefix).foregroundColor(.stackBlue) + + Text(bold).foregroundColor(.stackBlue).bold() + + Text(trailing).foregroundColor(.stackBlue)) + .font(.body14R) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var progressSection: some View { + if isRequerying { + VStack(spacing: .singlePadding) { + ProgressView() + .progressViewStyle(.circular) + Text("Confirming payment…") + .font(.body14M) + .foregroundColor(.navy02) + } + .padding(.vertical, .doublePadding) + } else { + VStack(spacing: .singlePadding) { + ZStack { + Circle() + .stroke(Color.gray01, lineWidth: 5) + .frame(width: 60, height: 60) + Image.messageBubbleLogo + } + HStack(spacing: 4) { + Text("Approve payment in") + .foregroundColor(.navy03) + Text(formattedRemaining) + .foregroundColor(countdownValueColor) + .animation(.easeInOut(duration: 0.2), value: countdownValueColor) + } + .font(.body14M) + } + .padding(.vertical, .doublePadding) + } + } +} + +@available(iOS 14.0, *) +struct CapitecPayAwaitingApprovalView_Previews: PreviewProvider { + static var previews: some View { + Group { + CapitecPayAwaitingApprovalView( + remainingSeconds: 96, + isRequerying: false, + onIveApprovedThePayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Countdown") + + CapitecPayAwaitingApprovalView( + remainingSeconds: 42, + isRequerying: false, + onIveApprovedThePayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Final 60s") + + CapitecPayAwaitingApprovalView( + remainingSeconds: 0, + isRequerying: true, + onIveApprovedThePayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Requerying") + } + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift new file mode 100644 index 0000000..00ecca0 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct CapitecPayIdentifierEntryView: View { + + @ObservedObject + var viewModel: CapitecPayViewModel + + let amount: AmountCurrency + let onChangePaymentMethod: () -> Void + + @State private var showValidationError = false + + var body: some View { + ScrollView { + VStack(spacing: .triplePadding) { + + Image("capitecPayLogo", bundle: .current) + .resizable() + .scaledToFit() + .frame(height: 40) + + Text(viewModel.identifier.prompt) + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + .animation(.easeInOut(duration: 0.2), value: viewModel.identifier) + + FormInput(title: "Confirm \(amount.description)", + enabled: viewModel.isValid, + action: viewModel.submitIdentifier, + secondaryButtonText: "Change payment method", + secondaryAction: onChangePaymentMethod) { + identifierPicker + identifierField + } + + CapitecPayInfoBanner( + text: "Have your phone ready to approve the payment in your Capitec app.") + } + .padding(.doublePadding) + } + } + + @ViewBuilder + private var identifierPicker: some FormInputItemView { + PickerFormInputView( + title: "", + items: CapitecPayIdentifier.allCases, + placeholder: viewModel.identifier.pickerTitle, + selectedItem: Binding( + get: { viewModel.identifier as CapitecPayIdentifier? }, + set: { newValue in + if let newValue = newValue, newValue != viewModel.identifier { + viewModel.identifier = newValue + viewModel.value = "" + } + })) + } + + @ViewBuilder + private var identifierField: some FormInputItemView { + TextFieldFormInputView( + title: "", + placeholder: viewModel.identifier.placeholder, + text: $viewModel.value, + keyboardType: keyboardType(for: viewModel.identifier), + maxLength: maxLength(for: viewModel.identifier), + inErrorState: $showValidationError, + defaultFocused: true, + accessoryView: accessoryView(for: viewModel.identifier)) + } + + private func keyboardType(for identifier: CapitecPayIdentifier) -> KeyboardType { + switch identifier { + case .cellphone: return .phonePad + case .idNumber: return .numberPad + case .accountNumber: return .numberPad + } + } + + private func maxLength(for identifier: CapitecPayIdentifier) -> Int? { + switch identifier { + case .cellphone: return 10 + case .idNumber: return 13 + case .accountNumber: return 20 + } + } + + @ViewBuilder + private func accessoryView(for identifier: CapitecPayIdentifier) -> some View { + switch identifier { + case .cellphone: + Image("southAfricaFlagLogo", bundle: .current) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + case .idNumber, .accountNumber: + EmptyView() + } + } +} + +@available(iOS 14.0, *) +struct CapitecPayIdentifierEntryView_Previews: PreviewProvider { + static var previews: some View { + let vm = CapitecPayViewModel( + chargeContainer: PreviewChargeContainer(), + transactionDetails: .example, + config: CapitecPayConfig( + transactionId: 5900549926, + transactionReference: "T_ref", + publicEncryptionKey: "test_key"), + repository: PreviewCapitecPayRepository()) + return CapitecPayIdentifierEntryView( + viewModel: vm, + amount: AmountCurrency(amount: 12500, currency: "ZAR"), + onChangePaymentMethod: {}) + } +} + +private struct PreviewChargeContainer: ChargeContainer { + func processSuccessfulTransaction(details: VerifyAccessCode) {} + func restartFromChannelSelection() {} +} + +private struct PreviewCapitecPayRepository: CapitecPayRepository { + func authenticate(identifier: CapitecPayIdentifier, + value: String, + transactionId: Int, + deviceId: String, + publicEncryptionKey: String) async throws -> CapitecPayDetails { + .example + } + func requery(transactionReference: String) async throws -> ChargeCardTransaction { + ChargeCardTransaction(status: .pending) + } + func listenForCapitecPayResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction { + ChargeCardTransaction(status: .success) + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift new file mode 100644 index 0000000..48ef482 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct CapitecPayInfoBanner: View { + + let text: String + + var body: some View { + HStack(alignment: .top, spacing: .singlePadding) { + Image(systemName: "info.circle.fill") + .foregroundColor(.stackBlue) + Text(text) + .font(.body14R) + .foregroundColor(.stackBlue) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.doublePadding) + .background(Color.stackBlue.opacity(0.08)) + .cornerRadius(.cornerRadius) + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayView.swift new file mode 100644 index 0000000..7f84af9 --- /dev/null +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct CapitecPayView: View { + + @StateObject + var viewModel: CapitecPayViewModel + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: CapitecPayConfig) { + self._viewModel = StateObject(wrappedValue: CapitecPayViewModel( + chargeContainer: chargeContainer, + transactionDetails: transactionDetails, + config: config)) + } + + var body: some View { + VStack(spacing: 0) { + switch viewModel.state { + case .identifierEntry: + CapitecPayIdentifierEntryView( + viewModel: viewModel, + amount: viewModel.transactionDetails.amountCurrency, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) + case .authenticating: + LoadingView(message: "Starting your Capitec Pay payment…") + case .awaitingApproval: + CapitecPayAwaitingApprovalView( + remainingSeconds: viewModel.remainingSeconds, + isRequerying: false, + onIveApprovedThePayment: viewModel.userTappedIveApprovedThePayment, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) + case .requerying: + CapitecPayAwaitingApprovalView( + remainingSeconds: 0, + isRequerying: true, + onIveApprovedThePayment: viewModel.userTappedIveApprovedThePayment, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) + case .error(let error): + ErrorView(message: error.message, + buttonText: "Try again", + buttonAction: { viewModel.state = .identifierEntry }) + case .fatalError(let error): + ErrorView(message: error.message, + automaticallyDismissWith: .init( + error: error, + transactionReference: viewModel.transactionDetails.reference)) + } + } + } +} diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index 729d181..42cdc94 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -70,6 +70,14 @@ struct ChargeView: View { ZapView(chargeContainer: viewModel, transactionDetails: transactionInformation, config: config) + case .capitecPay(let transactionInformation, let config): + CapitecPayView(chargeContainer: viewModel, + transactionDetails: transactionInformation, + config: config) + case .qr(let transactionInformation, let config): + QRView(chargeContainer: viewModel, + transactionDetails: transactionInformation, + config: config) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 6a24c7c..7ad7dfb 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -80,6 +80,23 @@ class ChargeViewModel: ObservableObject { result.append(.zap(config)) } + if response.paymentChannels.contains(.capitecPay), + let transactionId = response.transactionId { + let config = CapitecPayConfig( + transactionId: transactionId, + transactionReference: response.reference, + publicEncryptionKey: response.publicEncryptionKey) + result.append(.capitecPay(config)) + } + + if response.paymentChannels.contains(.qr), + let qrOptions = response.channelOptions?.qrCode, !qrOptions.isEmpty, + let transactionId = response.transactionId { + let entries = QRChannelDirectory.entries(for: qrOptions, + transactionId: transactionId) + result.append(contentsOf: entries) + } + return result } @@ -117,6 +134,27 @@ class ChargeViewModel: ObservableObject { config: config)) } + if !channels.contains(.card), + channels.count == 1, + case .capitecPay(let config) = channels[0] { + return .payment(type: .capitecPay(transactionInformation: response, + config: config)) + } + + if !channels.contains(.card), + channels.count == 1, + case .scanToPay(let config) = channels[0] { + return .payment(type: .qr(transactionInformation: response, + config: config)) + } + + if !channels.contains(.card), + channels.count == 1, + case .snapScan(let config) = channels[0] { + return .payment(type: .qr(transactionInformation: response, + config: config)) + } + return .channelSelection(transactionInformation: response, supportedChannels: channels) } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index 679fa93..771d028 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -63,6 +63,12 @@ class ChannelSelectionViewModel: ObservableObject { case .zap(let config): state = .payment(type: .zap(transactionInformation: self.information, config: config)) + case .capitecPay(let config): + state = .payment(type: .capitecPay(transactionInformation: self.information, + config: config)) + case .scanToPay(let config), .snapScan(let config): + state = .payment(type: .qr(transactionInformation: self.information, + config: config)) } } } diff --git a/Sources/PaystackUI/Charge/Models/ChannelOptions.swift b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift index 2a626ce..343c0b0 100644 --- a/Sources/PaystackUI/Charge/Models/ChannelOptions.swift +++ b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift @@ -4,6 +4,7 @@ import PaystackCore struct ChannelOptions: Equatable { var mobileMoney: [MobileMoneyChannel]? var bankTransfer: [String]? + var qrCode: [String]? } extension ChannelOptions { @@ -11,7 +12,8 @@ extension ChannelOptions { static func from(_ response: PaystackCore.ChannelOptions) -> Self { return ChannelOptions( mobileMoney: response.mobileMoney?.map({ MobileMoneyChannel.from($0) }), - bankTransfer: response.bankTransfer) + bankTransfer: response.bankTransfer, + qrCode: response.qrCode) } } diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index 48b6d8a..3df7c29 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -8,4 +8,8 @@ enum ChargePaymentType: Equatable { config: BankTransferConfig) case zap(transactionInformation: VerifyAccessCode, config: ZapConfig) + case capitecPay(transactionInformation: VerifyAccessCode, + config: CapitecPayConfig) + case qr(transactionInformation: VerifyAccessCode, + config: QRConfig) } diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index a40194b..d99fd23 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -6,6 +6,9 @@ enum SupportedChannel: Equatable, Identifiable { case mobileMoney(MobileMoneyChannel) case bankTransfer(BankTransferConfig) case zap(ZapConfig) + case capitecPay(CapitecPayConfig) + case scanToPay(QRConfig) + case snapScan(QRConfig) var id: String { switch self { @@ -17,6 +20,12 @@ enum SupportedChannel: Equatable, Identifiable { return "bank_transfer" case .zap: return "zap" + case .capitecPay: + return "capitec_pay" + case .scanToPay: + return "scan_to_pay" + case .snapScan: + return "snap_scan" } } @@ -30,6 +39,12 @@ enum SupportedChannel: Equatable, Identifiable { return config.provider == .pesalink ? "Pesalink" : "Bank Transfer" case .zap: return "Zap" + case .capitecPay: + return "Capitec Pay" + case .scanToPay: + return QRVariant.scanToPay.displayTitle + case .snapScan: + return QRVariant.snapScan.displayTitle } } @@ -45,6 +60,12 @@ enum SupportedChannel: Equatable, Identifiable { : Image("bankTransferLogo", bundle: .current) case .zap: return Image("zapSingleLogo", bundle: .current) + case .capitecPay: + return Image("capitecPayLogo", bundle: .current) + case .scanToPay: + return Image(QRVariant.scanToPay.logoAsset, bundle: .current) + case .snapScan: + return Image(QRVariant.snapScan.logoAsset, bundle: .current) } } diff --git a/Sources/PaystackUI/Charge/QR/Models/QRConfig.swift b/Sources/PaystackUI/Charge/QR/Models/QRConfig.swift new file mode 100644 index 0000000..7c49625 --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Models/QRConfig.swift @@ -0,0 +1,19 @@ +import Foundation + +struct QRConfig: Equatable { + let channelOption: String + let transactionId: Int + let variant: QRVariant +} + +extension QRConfig { + static let scanToPayExample = QRConfig( + channelOption: "MPASS_OLTI", + transactionId: 5900549926, + variant: .scanToPay) + + static let snapScanExample = QRConfig( + channelOption: "MPASS_OLTI", + transactionId: 5900549926, + variant: .snapScan) +} diff --git a/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift b/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift new file mode 100644 index 0000000..bba78db --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Models/QRDetails.swift @@ -0,0 +1,37 @@ +import Foundation +import PaystackCore + +struct QRDetails: Equatable { + let qrImageURL: URL + let qrReference: String? + let pusherChannel: String +} + +extension QRDetails { + + static func from(_ response: QRGenerateResponse, + variant: QRVariant) -> QRDetails? { + guard let url = URL(string: response.data.url) else { return nil } + return QRDetails( + qrImageURL: url, + qrReference: variant.showsQRReferenceRow ? response.data.qrCode : nil, + pusherChannel: response.data.channel) + } +} + +extension QRDetails { + + static var scanToPayExample: QRDetails { + QRDetails( + qrImageURL: URL(string: "https://example.paystack.co/qr.png")!, + qrReference: "1490884538", + pusherChannel: "api_mpass_olti_qr_51826223921246") + } + + static var snapScanExample: QRDetails { + QRDetails( + qrImageURL: URL(string: "https://example.paystack.co/qr.png")!, + qrReference: nil, + pusherChannel: "api_mpass_olti_qr_51826223921246") + } +} diff --git a/Sources/PaystackUI/Charge/QR/Models/QRState.swift b/Sources/PaystackUI/Charge/QR/Models/QRState.swift new file mode 100644 index 0000000..3bb6d0c --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Models/QRState.swift @@ -0,0 +1,9 @@ +import Foundation + +enum QRState: Equatable { + case loadingQR + case awaitingScan(QRDetails) + case verifying(QRDetails) + case error(ChargeError) + case fatalError(error: ChargeError) +} diff --git a/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift b/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift new file mode 100644 index 0000000..fdfebca --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Models/QRVariant.swift @@ -0,0 +1,42 @@ +import Foundation + +enum QRVariant: String, Equatable { + case scanToPay + case snapScan + + var displayTitle: String { + switch self { + case .scanToPay: + return "Scan to Pay" + case .snapScan: + return "SnapScan" + } + } + + var logoAsset: String { + switch self { + case .scanToPay: + return "scanToPayLogo" + case .snapScan: + return "snapScanLogo" + } + } + + var instructionCopy: String { + switch self { + case .scanToPay: + return "Open any Scan to Pay app on your phone to scan the QR code" + case .snapScan: + return "Scan the QR code below in your SnapScan mobile app to complete the payment" + } + } + + var showsQRReferenceRow: Bool { + switch self { + case .scanToPay: + return true + case .snapScan: + return false + } + } +} diff --git a/Sources/PaystackUI/Charge/QR/Repository/QRRepository.swift b/Sources/PaystackUI/Charge/QR/Repository/QRRepository.swift new file mode 100644 index 0000000..3f6cfbe --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Repository/QRRepository.swift @@ -0,0 +1,51 @@ +import Foundation +import PaystackCore + +protocol QRRepository { + func generate(reference: String, + channelOption: String, + variant: QRVariant) async throws -> QRDetails + + func listenForResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction + + func checkPending(accessCode: String) async throws -> ChargeCardTransaction +} + +struct QRRepositoryImplementation: QRRepository { + + let paystack: Paystack + + init() { + self.paystack = PaystackContainer.instance.retrieve() + } + + func generate(reference: String, + channelOption: String, + variant: QRVariant) async throws -> QRDetails { + let request = QRGenerateRequest( + reference: reference, + channel: channelOption) + let response = try await paystack.generateQR(request).async() + guard response.status, !response.data.errors else { + throw ChargeError(message: response.message) + } + guard let details = QRDetails.from(response, variant: variant) else { + throw ChargeError(message: "The QR image URL is invalid") + } + return details + } + + func listenForResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction { + let response = try await paystack + .listenForQRResponse(onChannel: channelName).async() + return ChargeCardTransaction.from(response) + } + + func checkPending(accessCode: String) async throws -> ChargeCardTransaction { + let response = try await paystack + .checkPendingCharge(forAccessCode: accessCode).async() + return ChargeCardTransaction.from(response) + } +} diff --git a/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift b/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift new file mode 100644 index 0000000..62605a1 --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift @@ -0,0 +1,162 @@ +import Foundation +import PaystackCore + +class QRViewModel: ObservableObject { + + static var failedFallbackMessage = "The transaction could not be confirmed" + static var checkPendingFallbackMessage = + "We couldn't confirm your payment yet — please try again in a moment" + + let chargeContainer: ChargeContainer + let repository: QRRepository + let transactionDetails: VerifyAccessCode + let config: QRConfig + + @Published + var state: QRState = .loadingQR + + @Published + var inlineBanner: String? + + private var pusherTask: Task? + private var checkPendingTask: Task? + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: QRConfig, + repository: QRRepository = QRRepositoryImplementation()) { + self.chargeContainer = chargeContainer + self.transactionDetails = transactionDetails + self.config = config + self.repository = repository + } + + deinit { + pusherTask?.cancel() + checkPendingTask?.cancel() + } + + var variant: QRVariant { config.variant } + + @MainActor + func onAppear() async { + guard case .loadingQR = state else { return } + await generate() + } + + @MainActor + func retry() async { + cancelAllTasks() + inlineBanner = nil + state = .loadingQR + await generate() + } + + @MainActor + private func generate() async { + do { + let details = try await repository.generate( + reference: "\(config.transactionId)", + channelOption: config.channelOption, + variant: config.variant) + state = .awaitingScan(details) + startListeningForPusher(on: details.pusherChannel) + } catch { + state = .error(ChargeError(error: error)) + } + } + + @MainActor + func userTappedICompletedPayment() { + guard case .awaitingScan(let details) = state else { return } + cancelPusherTask() + inlineBanner = nil + state = .verifying(details) + + checkPendingTask?.cancel() + checkPendingTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await self.repository.checkPending( + accessCode: self.transactionDetails.accessCode) + await self.reactToCheckPendingResult(result, details: details) + } catch { + await self.handleCheckPendingFailure(details: details, + message: nil) + } + } + } + + @MainActor + func userTappedChangePaymentMethod() { + cancelAllTasks() + chargeContainer.restartFromChannelSelection() + } + + @MainActor + private func reactToCheckPendingResult(_ result: ChargeCardTransaction, + details: QRDetails) { + switch result.status { + case .success: + cancelAllTasks() + chargeContainer.processSuccessfulTransaction(details: transactionDetails) + case .failed: + cancelAllTasks() + let message = result.message ?? result.displayText ?? Self.failedFallbackMessage + state = .error(ChargeError(message: message)) + default: + handleCheckPendingFailure(details: details, message: nil) + } + } + + @MainActor + private func handleCheckPendingFailure(details: QRDetails, message: String?) { + inlineBanner = message ?? Self.checkPendingFallbackMessage + state = .awaitingScan(details) + startListeningForPusher(on: details.pusherChannel) + } + + private func startListeningForPusher(on channel: String) { + pusherTask?.cancel() + pusherTask = Task { [weak self] in + await self?.listenLoop(on: channel) + } + } + + private func listenLoop(on channel: String) async { + do { + let update = try await repository.listenForResponse(onChannel: channel) + await processTransactionUpdate(update) + } catch { + Logger.error("QR Pusher await failed: %@", + arguments: error.localizedDescription) + } + } + + @MainActor + func processTransactionUpdate(_ update: ChargeCardTransaction) async { + switch update.status { + case .success: + cancelAllTasks() + chargeContainer.processSuccessfulTransaction(details: transactionDetails) + case .failed: + cancelAllTasks() + let message = update.message ?? Self.failedFallbackMessage + state = .error(ChargeError(message: message)) + default: + Logger.info("QR: non-terminal transaction status %@", + arguments: String(describing: update.status)) + } + } + + private func cancelPusherTask() { + pusherTask?.cancel() + pusherTask = nil + } + + private func cancelAllTasks() { + cancelPusherTask() + checkPendingTask?.cancel() + checkPendingTask = nil + } +} diff --git a/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift new file mode 100644 index 0000000..fac0bc3 --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift @@ -0,0 +1,146 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14.0, *) +struct QRDisplayView: View { + + let variant: QRVariant + let details: QRDetails + let amount: AmountCurrency + let inlineBanner: String? + let onICompletedPayment: () -> Void + let onChangePaymentMethod: () -> Void + + @State private var showCopiedToast = false + + var body: some View { + ScrollView { + VStack(spacing: .triplePadding) { + + headerLogo + + Text(variant.instructionCopy) + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + qrCodeBlock + + Text(amount.description) + .font(.body16M) + .foregroundColor(.stackBlue) + + if variant.showsQRReferenceRow, let reference = details.qrReference { + qrReferenceRow(reference: reference) + } + + if let banner = inlineBanner { + inlineBannerView(banner) + } + + actionButtons + } + .padding(.doublePadding) + } + .copiedToast(isPresented: $showCopiedToast) + } + + private var headerLogo: some View { + Image(variant.logoAsset, 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 func qrReferenceRow(reference: String) -> some View { + Button(action: { copy(reference) }) { + HStack(spacing: .singlePadding) { + Text(reference) + .font(.body16M) + .foregroundColor(.stackBlue) + Image(systemName: "doc.on.doc") + .foregroundColor(.navy02) + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + } + + private func inlineBannerView(_ text: String) -> some View { + Text(text) + .font(.body14M) + .foregroundColor(.warning02) + .multilineTextAlignment(.center) + .padding(.singlePadding) + .frame(maxWidth: .infinity) + .background(Color.warning02.opacity(0.08)) + .cornerRadius(.cornerRadius) + } + + private var actionButtons: some View { + VStack(spacing: .singlePadding) { + Button("I've completed payment", action: onICompletedPayment) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + + Button("Change payment method", action: onChangePaymentMethod) + .foregroundColor(.navy02) + .font(.body14M) + .padding(.top, .singlePadding) + } + } + + private func copy(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #endif + withAnimation(.easeInOut(duration: 0.2)) { + showCopiedToast = true + } + } +} + +@available(iOS 14.0, *) +struct QRDisplayView_Previews: PreviewProvider { + static var previews: some View { + Group { + QRDisplayView( + variant: .scanToPay, + details: .scanToPayExample, + amount: AmountCurrency(amount: 4500, currency: "ZAR"), + inlineBanner: nil, + onICompletedPayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Scan to Pay — awaiting scan") + + QRDisplayView( + variant: .snapScan, + details: .snapScanExample, + amount: AmountCurrency(amount: 4500, currency: "ZAR"), + inlineBanner: nil, + onICompletedPayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Snap Scan — awaiting scan") + + QRDisplayView( + variant: .scanToPay, + details: .scanToPayExample, + amount: AmountCurrency(amount: 4500, currency: "ZAR"), + inlineBanner: "We couldn't confirm your payment yet — please try again in a moment", + onICompletedPayment: {}, + onChangePaymentMethod: {}) + .previewDisplayName("Scan to Pay — with inline banner (post-pending)") + } + } +} diff --git a/Sources/PaystackUI/Charge/QR/Views/QRView.swift b/Sources/PaystackUI/Charge/QR/Views/QRView.swift new file mode 100644 index 0000000..e91595d --- /dev/null +++ b/Sources/PaystackUI/Charge/QR/Views/QRView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import PaystackCore + +@available(iOS 14.0, *) +struct QRView: View { + + @StateObject + var viewModel: QRViewModel + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: QRConfig) { + self._viewModel = StateObject(wrappedValue: QRViewModel( + chargeContainer: chargeContainer, + transactionDetails: transactionDetails, + config: config)) + } + + var body: some View { + VStack(spacing: 0) { + switch viewModel.state { + case .loadingQR: + LoadingView(message: "Generating your QR code…") + case .awaitingScan(let details): + QRDisplayView( + variant: viewModel.variant, + details: details, + amount: viewModel.transactionDetails.amountCurrency, + inlineBanner: viewModel.inlineBanner, + onICompletedPayment: viewModel.userTappedICompletedPayment, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) + case .verifying: + LoadingView(message: "Verifying your payment…") + case .error(let error): + ErrorView(message: error.message, + buttonText: "Try again", + buttonAction: { Task { await viewModel.retry() } }) + case .fatalError(let error): + ErrorView(message: error.message, + automaticallyDismissWith: .init( + error: error, + transactionReference: viewModel.transactionDetails.reference)) + } + } + .task { await viewModel.onAppear() } + } +} diff --git a/Sources/PaystackUI/Components/CopiedToast.swift b/Sources/PaystackUI/Components/CopiedToast.swift new file mode 100644 index 0000000..df8d5be --- /dev/null +++ b/Sources/PaystackUI/Components/CopiedToast.swift @@ -0,0 +1,57 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct CopiedToast: View { + + let text: String + + var body: some View { + Text(text) + .font(.body14M) + .foregroundColor(.white) + .padding(.horizontal, .doublePadding) + .padding(.vertical, .singlePadding) + .background(Color.stackBlue.opacity(0.9)) + .cornerRadius(.cornerRadius) + } +} + +@available(iOS 14.0, *) +struct CopiedToastModifier: ViewModifier { + + @Binding var isPresented: Bool + let text: String + let dwellSeconds: Double + + func body(content: Content) -> some View { + ZStack(alignment: .bottom) { + content + if isPresented { + CopiedToast(text: text) + .padding(.bottom, .doublePadding) + .transition(.opacity) + .onAppear { + Task { + try? await Task.sleep( + nanoseconds: UInt64(dwellSeconds * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.25)) { + isPresented = false + } + } + } + } + } + .animation(.easeInOut(duration: 0.25), value: isPresented) + } +} + +@available(iOS 14.0, *) +extension View { + func copiedToast(isPresented: Binding, + text: String = "Copied", + dwellSeconds: Double = 1.5) -> some View { + modifier(CopiedToastModifier(isPresented: isPresented, + text: text, + dwellSeconds: dwellSeconds)) + } +} diff --git a/Sources/PaystackUI/Utils/QRChannelDirectory.swift b/Sources/PaystackUI/Utils/QRChannelDirectory.swift new file mode 100644 index 0000000..91c83ed --- /dev/null +++ b/Sources/PaystackUI/Utils/QRChannelDirectory.swift @@ -0,0 +1,22 @@ +import Foundation + +enum QRChannelDirectory { + + static func entries(for options: [String], + transactionId: Int) -> [SupportedChannel] { + var result: [SupportedChannel] = [] + + if options.contains("MPASS_OLTI") { + result.append(.scanToPay(QRConfig( + channelOption: "MPASS_OLTI", + transactionId: transactionId, + variant: .scanToPay))) + result.append(.snapScan(QRConfig( + channelOption: "MPASS_OLTI", + transactionId: transactionId, + variant: .snapScan))) + } + + return result + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift b/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift new file mode 100644 index 0000000..4c67333 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import PaystackCore + +final class CapitecPayTests: PSTestCase { + + let apiKey = "testsk_Example" + + var serviceUnderTest: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + serviceUnderTest = try PaystackBuilder.newInstance + .setKey(apiKey) + .build() + } + + func testAuthenticateCapitecPayHitsCorrectURLAndMethodAndHeaders() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/authenticate") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .expectHeader("Content-Type", "application/json") + .andReturn(json: "CapitecPayAuthenticateResponse") + + let request = CapitecPayAuthenticateRequest( + clientdata: "encrypted-blob", + trans: "5900549926", + device: "312d74aad3c2b37d5029755bffd50d2f") + _ = try await serviceUnderTest.authenticateCapitecPay(request).async() + } + + func testAuthenticateCapitecPayDecodesAllFieldsFromResponse() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/authenticate") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "CapitecPayAuthenticateResponse") + + let request = CapitecPayAuthenticateRequest( + clientdata: "encrypted-blob", + trans: "5900549926", + device: "device-id") + let result = try await serviceUnderTest.authenticateCapitecPay(request).async() + + XCTAssertEqual(result.status, true) + XCTAssertEqual(result.type, "success") + XCTAssertEqual(result.code, "ok") + XCTAssertEqual(result.message, "Charge pending") + XCTAssertEqual(result.data.status, "success") + XCTAssertEqual(result.data.timeToLive, 120) + XCTAssertEqual(result.data.expiryDate, + DateFormatter.paystackFormatter.date(from: "2026-07-07T12:29:20.000Z")) + } + + func testRequeryCapitecPayHitsCorrectURLWithTransactionReferenceInPath() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + _ = try await serviceUnderTest + .requeryCapitecPay(transactionReference: "T_ref_5900549926") + .async() + } + + func testRequeryCapitecPayDecodesChargeResponseShape() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + let result = try await serviceUnderTest + .requeryCapitecPay(transactionReference: "T_ref_5900549926") + .async() + + XCTAssertEqual(result.status, true) + XCTAssertEqual(result.data.reference, "36xz3b9rie9ppvz") + } + + func testListenForCapitecPayResponseSubscribesToProvidedChannel() async throws { + let channelName = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherSuccess") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .success) + } + + func testListenForCapitecPayResponseDecodesFailedShape() async throws { + let channelName = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherFailed") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "Bank declined") + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/QRTests.swift b/Tests/PaystackSDKTests/API/Charge/QRTests.swift new file mode 100644 index 0000000..3cd70ae --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/QRTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import PaystackCore + +final class QRTests: PSTestCase { + + let apiKey = "testsk_Example" + + var serviceUnderTest: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + serviceUnderTest = try PaystackBuilder.newInstance + .setKey(apiKey) + .build() + } + + func testGenerateQRHitsCorrectURLAndMethodAndHeaders() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/offline/qr/generate") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .expectHeader("Content-Type", "application/json") + .andReturn(json: "QRGenerateResponse") + + let request = QRGenerateRequest( + reference: "T_ref_5900549926", + channel: "MPASS_OLTI") + _ = try await serviceUnderTest.generateQR(request).async() + } + + func testGenerateQRDecodesAllFieldsFromResponse() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/offline/qr/generate") + .expectMethod(.post) + .andReturn(json: "QRGenerateResponse") + + let request = QRGenerateRequest( + reference: "T_ref_5900549926", + channel: "MPASS_OLTI") + let result = try await serviceUnderTest.generateQR(request).async() + + XCTAssertEqual(result.status, true) + XCTAssertEqual(result.message, "QR successfully generated") + XCTAssertEqual(result.data.errors, false) + XCTAssertEqual(result.data.qrCode, "1490884538") + XCTAssertEqual(result.data.status, "success") + XCTAssertEqual(result.data.channel, "api_mpass_olti_qr_51826223921246") + XCTAssertTrue(result.data.url.hasPrefix("https://s3.eu-west-1.amazonaws.com/")) + } + + func testGenerateQRDefaultsSourceToMobilePos() { + let request = QRGenerateRequest( + reference: "T_ref_5900549926", + channel: "MPASS_OLTI") + XCTAssertEqual(request.source, "mobile-pos") + } + + func testListenForQRResponseSubscribesToProvidedChannel() async throws { + let channelName = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "QRPusherSuccess") + + let result = try await serviceUnderTest + .listenForQRResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .success) + } + + func testListenForQRResponseDecodesFailedShape() async throws { + let channelName = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "QRPusherFailed") + + let result = try await serviceUnderTest + .listenForQRResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "Wallet declined the payment") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift new file mode 100644 index 0000000..2335c5a --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift @@ -0,0 +1,332 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class CapitecPayViewModelTests: XCTestCase { + + var serviceUnderTest: CapitecPayViewModel! + var mockChargeContainer: MockChargeContainer! + var mockRepository: MockCapitecPayRepository! + + override func setUpWithError() throws { + try super.setUpWithError() + mockChargeContainer = MockChargeContainer() + mockRepository = MockCapitecPayRepository() + serviceUnderTest = CapitecPayViewModel( + chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + } + + override func tearDownWithError() throws { + CapitecPayViewModel.requeryPollIntervalSeconds = 10 + CapitecPayViewModel.requeryMaxIterations = 18 + try super.tearDownWithError() + } + + func testInitialStateIsIdentifierEntry() { + XCTAssertEqual(serviceUnderTest.state, .identifierEntry) + XCTAssertEqual(serviceUnderTest.identifier, .cellphone) + XCTAssertEqual(serviceUnderTest.value, "") + } + + func testIsValidWithValidCellphoneNumber() { + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + XCTAssertTrue(serviceUnderTest.isValid) + } + + func testIsValidWithInvalidCellphoneNumber() { + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0509603632" + XCTAssertFalse(serviceUnderTest.isValid) + } + + func testIsValidWithValidSAIDNumber() { + serviceUnderTest.identifier = .idNumber + serviceUnderTest.value = "8001015009087" + XCTAssertTrue(serviceUnderTest.isValid) + } + + func testIsValidWithInvalidSAIDNumber() { + serviceUnderTest.identifier = .idNumber + serviceUnderTest.value = "1234567890123" + XCTAssertFalse(serviceUnderTest.isValid) + } + + func testIsValidWithNonEmptyAccountNumber() { + serviceUnderTest.identifier = .accountNumber + serviceUnderTest.value = "123456789" + XCTAssertTrue(serviceUnderTest.isValid) + } + + func testIsValidWithEmptyAccountNumber() { + serviceUnderTest.identifier = .accountNumber + serviceUnderTest.value = "" + XCTAssertFalse(serviceUnderTest.isValid) + } + + func testSubmitIdentifierWhenInvalidDoesNotCallRepository() async { + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "invalid" + + await serviceUnderTest.submitIdentifier() + + XCTAssertEqual(mockRepository.authenticateCallCount, 0) + XCTAssertEqual(serviceUnderTest.state, .identifierEntry) + } + + func testSubmitIdentifierForwardsIdentifierAndValueToRepository() async { + mockRepository.expectedDetails = .example + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + + XCTAssertEqual(mockRepository.authenticateCallCount, 1) + XCTAssertEqual(mockRepository.authenticateSubmitted.identifier, .cellphone) + XCTAssertEqual(mockRepository.authenticateSubmitted.value, "0609603632") + XCTAssertEqual(mockRepository.authenticateSubmitted.transactionId, 5900549926) + XCTAssertEqual(mockRepository.authenticateSubmitted.publicEncryptionKey, + "test_encryption_key") + } + + func testSubmitIdentifierOnSuccessTransitionsToAwaitingApproval() async { + let expectedDetails = CapitecPayDetails.example + mockRepository.expectedDetails = expectedDetails + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + + if case .awaitingApproval(let details) = serviceUnderTest.state { + XCTAssertEqual(details, expectedDetails) + } else { + XCTFail("Expected .awaitingApproval, got \(serviceUnderTest.state)") + } + XCTAssertEqual(serviceUnderTest.remainingSeconds, 120) + } + + func testSubmitIdentifierOnErrorTransitionsToError() async { + let expectedError = PaystackError.response(code: 500, message: "Boom") + mockRepository.expectedErrorResponse = expectedError + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(error: expectedError))) + } + + func testProcessTransactionUpdateWithSuccessRoutesToContainer() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .success)) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testProcessTransactionUpdateWithFailedTransitionsToError() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .failed, message: "Bank declined")) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testProcessTransactionUpdateWithFailedFallsBackToDefaultMessage() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .failed)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: CapitecPayViewModel.failedFallbackMessage))) + } + + func testProcessTransactionUpdateWithNonTerminalStatusDoesNotChangeState() async { + let stateBefore = serviceUnderTest.state + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .pending)) + XCTAssertEqual(serviceUnderTest.state, stateBefore) + } + + func testUserTappedIveApprovedThePaymentFiresOneRequery() async { + mockRepository.expectedRequeryResults = [ + ChargeCardTransaction(status: .pending) + ] + + await MainActor.run { + serviceUnderTest.userTappedIveApprovedThePayment() + } + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(mockRepository.requeryCallCount, 1) + XCTAssertEqual(mockRepository.lastRequeryReference, + serviceUnderTest.transactionDetails.reference) + } + + func testUserTappedIveApprovedWithSuccessRoutesToContainer() async { + mockRepository.expectedRequeryResults = [ + ChargeCardTransaction(status: .success) + ] + + await MainActor.run { + serviceUnderTest.userTappedIveApprovedThePayment() + } + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + @MainActor + func testUserTappedChangePaymentMethodRestartsChannelSelection() { + serviceUnderTest.userTappedChangePaymentMethod() + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + @MainActor + func testDisplayTransactionErrorSetsStateToErrorWithGivenError() async { + let error = ChargeError(message: "Something broke") + await serviceUnderTest.displayTransactionError(error) + XCTAssertEqual(serviceUnderTest.state, .error(error)) + } + + // MARK: - Pusher listen loop (PR CP-E) + + func testSubmitIdentifierStartsListenLoopOnReturnedChannel() async { + mockRepository.expectedDetails = .example + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertGreaterThanOrEqual(mockRepository.listenCallCount, 1) + XCTAssertEqual(mockRepository.lastListenedChannel, "CAPITECPAY_5900549926") + } + + func testListenResolvesOnSuccessAndRoutesToContainer() async { + mockRepository.expectedDetails = .example + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .success) + ] + let expectation = expectation(description: "container receives success") + mockChargeContainer.onProcessSuccessfulTransaction = { expectation.fulfill() } + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testListenResolvesOnFailedStatusToErrorState() async { + mockRepository.expectedDetails = .example + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .failed, message: "Bank declined") + ] + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + // MARK: - Countdown + requery loop (PR CP-E / CP-F) + + func testCountdownExpiryTransitionsToRequerying() async throws { + mockRepository.expectedDetails = CapitecPayDetails( + timeToLive: 1, + expiryDate: Date().addingTimeInterval(1), + pusherChannel: "CAPITECPAY_5900549926") + CapitecPayViewModel.requeryPollIntervalSeconds = 100 + CapitecPayViewModel.requeryMaxIterations = 0 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 1_400_000_000) + + if case .requerying = serviceUnderTest.state { + // ok + } else if case .fatalError = serviceUnderTest.state { + // ok — 0 iterations tips immediately to fatal, still confirms + // the requerying-loop path fired. + } else { + XCTFail("Expected .requerying or .fatalError, got \(serviceUnderTest.state)") + } + } + + func testRequeryLoopResolvesOnSuccess() async throws { + mockRepository.expectedDetails = CapitecPayDetails( + timeToLive: 1, + expiryDate: Date().addingTimeInterval(1), + pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedRequeryResults = [ + ChargeCardTransaction(status: .success) + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 5 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 2_500_000_000) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testRequeryLoopResolvesOnFailed() async throws { + mockRepository.expectedDetails = CapitecPayDetails( + timeToLive: 1, + expiryDate: Date().addingTimeInterval(1), + pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedRequeryResults = [ + ChargeCardTransaction(status: .failed, displayText: nil, message: "Bank declined") + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 5 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 2_500_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testRequeryLoopTransitionsToFatalErrorAfterMaxIterations() async throws { + mockRepository.expectedDetails = CapitecPayDetails( + timeToLive: 1, + expiryDate: Date().addingTimeInterval(1), + pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedRequeryResults = [ + ChargeCardTransaction(status: .pending), + ChargeCardTransaction(status: .pending) + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 2 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 4_000_000_000) + + if case .fatalError(let error) = serviceUnderTest.state { + XCTAssertEqual(error.message, CapitecPayViewModel.failedFallbackMessage) + } else { + XCTFail("Expected .fatalError, got \(serviceUnderTest.state)") + } + } +} + +private extension CapitecPayConfig { + static let example = CapitecPayConfig( + transactionId: 5900549926, + transactionReference: "T_ref_5900549926", + publicEncryptionKey: "test_encryption_key") +} diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPay/SouthAfricanValidatorTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/SouthAfricanValidatorTests.swift new file mode 100644 index 0000000..d56bca2 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/SouthAfricanValidatorTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import PaystackUI + +final class SouthAfricanPhoneValidatorTests: XCTestCase { + + func testAcceptsValidCellphoneNumberStartingWith06() { + XCTAssertTrue(SouthAfricanPhoneValidator.isValid("0609603632")) + } + + func testAcceptsValidCellphoneNumberStartingWith07() { + XCTAssertTrue(SouthAfricanPhoneValidator.isValid("0721234567")) + } + + func testAcceptsValidCellphoneNumberStartingWith08() { + XCTAssertTrue(SouthAfricanPhoneValidator.isValid("0821234567")) + } + + func testRejectsCellphoneNumberStartingWithWrongPrefix() { + XCTAssertFalse(SouthAfricanPhoneValidator.isValid("0509603632")) + } + + func testRejectsCellphoneNumberThatIsTooShort() { + XCTAssertFalse(SouthAfricanPhoneValidator.isValid("060960363")) + } + + func testRejectsCellphoneNumberThatIsTooLong() { + XCTAssertFalse(SouthAfricanPhoneValidator.isValid("06096036320")) + } + + func testRejectsCellphoneNumberContainingLetters() { + XCTAssertFalse(SouthAfricanPhoneValidator.isValid("06AB603632")) + } + + func testRejectsEmptyString() { + XCTAssertFalse(SouthAfricanPhoneValidator.isValid("")) + } +} + +final class SouthAfricanIDValidatorTests: XCTestCase { + + func testAcceptsAKnownValidSouthAfricanID() { + XCTAssertTrue(SouthAfricanIDValidator.isValid("8001015009087")) + } + + func testRejectsIDWithWrongLength() { + XCTAssertFalse(SouthAfricanIDValidator.isValid("800101500908")) + XCTAssertFalse(SouthAfricanIDValidator.isValid("80010150090870")) + } + + func testRejectsIDWithNonNumericCharacters() { + XCTAssertFalse(SouthAfricanIDValidator.isValid("8001015X09087")) + } + + func testRejectsIDWithInvalidDatePortion() { + XCTAssertFalse(SouthAfricanIDValidator.isValid("9902305009087")) + } + + func testRejectsIDWithInvalidLuhnChecksum() { + XCTAssertFalse(SouthAfricanIDValidator.isValid("8001015009088")) + } + + func testRejectsEmptyString() { + XCTAssertFalse(SouthAfricanIDValidator.isValid("")) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift new file mode 100644 index 0000000..f602cda --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class CapitecPayRepositoryImplementationTests: PSTestCase { + + let apiKey = "testsk_Example" + var serviceUnderTest: CapitecPayRepositoryImplementation! + var paystack: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + paystack = try PaystackBuilder.newInstance.setKey(apiKey).build() + PaystackContainer.instance.store(paystack) + serviceUnderTest = CapitecPayRepositoryImplementation( + cryptography: FakeCryptography()) + } + + func testAuthenticateHitsCorrectURLAndMethodAndHeaders() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/authenticate") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "CapitecPayAuthenticateResponse") + + _ = try await serviceUnderTest.authenticate( + identifier: .cellphone, + value: "0609603632", + transactionId: 5900549926, + deviceId: "device-id", + publicEncryptionKey: "test_key") + } + + func testAuthenticateMapsResponseIntoCapitecPayDetails() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/authenticate") + .expectMethod(.post) + .andReturn(json: "CapitecPayAuthenticateResponse") + + let result = try await serviceUnderTest.authenticate( + identifier: .cellphone, + value: "0609603632", + transactionId: 5900549926, + deviceId: "device-id", + publicEncryptionKey: "test_key") + + XCTAssertEqual(result.timeToLive, 120) + XCTAssertEqual(result.pusherChannel, "CAPITECPAY_5900549926") + } + + func testRequeryHitsCorrectURLWithTransactionReferenceInPath() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + _ = try await serviceUnderTest.requery( + transactionReference: "T_ref_5900549926") + } + + func testRequeryMapsResponseIntoChargeCardTransaction() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") + .expectMethod(.post) + .andReturn(json: "ChargeAuthenticationResponse") + + let result = try await serviceUnderTest.requery( + transactionReference: "T_ref_5900549926") + + XCTAssertEqual(result.status, .success) + } + + func testListenForCapitecPayResponseSubscribesToProvidedChannel() async throws { + let channel = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherSuccess") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channel) + + XCTAssertEqual(result.status, .success) + } +} + +private struct FakeCryptography: CryptographyProtocol { + func encryptPKCS1(text: String, publicKey: String) throws -> String { + return "fake-encrypted:\(text)" + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 18dc927..a0bb601 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -29,7 +29,8 @@ final class ChargeRepositoryImplementationTests: PSTestCase { .init(key: "MPESA", value: "M-PESA", isNew: true, phoneNumberRegex: phoneNumberRegex), .init(key: "MPESA_OFF", value: "M-PESA", isNew: false, phoneNumberRegex: phoneNumberRegex) ], - bankTransfer: ["wema-bank", "titan-paystack", "paystack-mfb"]) + bankTransfer: ["wema-bank", "titan-paystack", "paystack-mfb"], + qrCode: ["visa"]) let expectedMerchantSettings = MerchantChannelSettings( bankTransfer: BankTransferMerchantSettings(fulfilLateNotification: true)) let expectedResult = VerifyAccessCode(email: "test@email.com", diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 1899b99..bc9830e 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -472,6 +472,147 @@ final class ChargeViewModelTests: PSTestCase { } } + // MARK: - Capitec Pay resolver (PR CP-B) + + func testCapitecPayPromotesWhenChannelPresentAndTransactionIdPresent() async { + let response = VerifyAccessCode.with( + channels: [.capitecPay], + transactionId: 5900549926) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.capitecPay(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.transactionId, 5900549926) + XCTAssertEqual(config.transactionReference, response.reference) + XCTAssertEqual(config.publicEncryptionKey, response.publicEncryptionKey) + } else { + XCTFail("Expected .payment(.capitecPay(_, _)), got \(serviceUnderTest.transactionState)") + } + } + + func testCapitecPayDoesNotPromoteWhenChannelAbsent() async { + let response = VerifyAccessCode.with( + channels: [.card], + transactionId: 5900549926) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testCapitecPayDoesNotPromoteWhenTransactionIdMissing() async { + let response = VerifyAccessCode.with( + channels: [.capitecPay], + transactionId: nil) + 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 testAutoRoutesToCapitecPayWhenItIsTheOnlyChannel() async { + let response = VerifyAccessCode.with( + channels: [.capitecPay], + transactionId: 5900549926) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.capitecPay) = serviceUnderTest.transactionState { + } else { + XCTFail("Expected auto-route to .payment(.capitecPay), got \(serviceUnderTest.transactionState)") + } + } + + // MARK: - QR resolver (PR QR-B) + + func testQRSurfacesBothScanToPayAndSnapScanWhenMPASSOLTIPresent() async { + let response = VerifyAccessCode.with( + channels: [.card, .qr], + qrCode: ["MPASS_OLTI"]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .channelSelection(_, let channels) = serviceUnderTest.transactionState { + let ids = channels.map { $0.id } + XCTAssertTrue(ids.contains("scan_to_pay"), + "Expected Scan to Pay in \(ids)") + XCTAssertTrue(ids.contains("snap_scan"), + "Expected Snap Scan in \(ids)") + let scanToPayIndex = ids.firstIndex(of: "scan_to_pay") + let snapScanIndex = ids.firstIndex(of: "snap_scan") + XCTAssertNotNil(scanToPayIndex) + XCTAssertNotNil(snapScanIndex) + if let s = scanToPayIndex, let n = snapScanIndex { + XCTAssertLessThan(s, n, + "Scan to Pay should appear before Snap Scan") + } + } else { + XCTFail("Expected .channelSelection, got \(serviceUnderTest.transactionState)") + } + } + + func testQRDoesNotSurfaceWhenChannelAbsent() async { + let response = VerifyAccessCode.with( + channels: [.card], + qrCode: ["MPASS_OLTI"]) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testQRDoesNotSurfaceWhenChannelOptionsEmpty() async { + let response = VerifyAccessCode.with( + channels: [.card, .qr], + qrCode: []) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testQRDoesNotSurfaceForUnknownChannelOptionCode() async { + let response = VerifyAccessCode.with( + channels: [.qr], + qrCode: ["FUTURE_PROVIDER_XYZ"]) + 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 testQRDoesNotSurfaceWhenTransactionIdMissing() async { + let response = VerifyAccessCode.with( + channels: [.qr], + qrCode: ["MPASS_OLTI"], + transactionId: nil) + 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 testRestartFromChannelSelectionRebuildsChannelSelectionFromCachedDetails() async { let response = VerifyAccessCode.with( channels: [.card, .bankTransfer], @@ -597,13 +738,15 @@ private extension VerifyAccessCode { currency: String = "USD", mobileMoney: [MobileMoneyChannel]? = nil, bankTransferProviders: [String]? = nil, + qrCode: [String]? = nil, fulfilLateNotification: Bool? = nil, transactionId: Int? = 1234, supportedBanks: [SupportedBank]? = nil) -> Self { let channelOptions: PaystackUI.ChannelOptions? = { - if mobileMoney == nil && bankTransferProviders == nil { return nil } + if mobileMoney == nil && bankTransferProviders == nil && qrCode == nil { return nil } return PaystackUI.ChannelOptions(mobileMoney: mobileMoney, - bankTransfer: bankTransferProviders) + bankTransfer: bankTransferProviders, + qrCode: qrCode) }() let settings: MerchantChannelSettings? = fulfilLateNotification.map { MerchantChannelSettings( diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift new file mode 100644 index 0000000..864a68d --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import PaystackCore +@testable import PaystackUI + +class MockCapitecPayRepository: CapitecPayRepository { + + var expectedDetails: CapitecPayDetails? + var expectedRequeryResults: [ChargeCardTransaction] = [] + var expectedErrorResponse: Error? + + var expectedListenResponses: [ChargeCardTransaction] = [] + var expectedListenError: Error? + + var authenticateSubmitted: (identifier: CapitecPayIdentifier, + value: String, + transactionId: Int, + deviceId: String, + publicEncryptionKey: String) = (.cellphone, "", 0, "", "") + private(set) var authenticateCallCount = 0 + + private(set) var requeryCallCount = 0 + private(set) var lastRequeryReference: String? + + private(set) var listenCallCount = 0 + private(set) var lastListenedChannel: String? + + func authenticate(identifier: CapitecPayIdentifier, + value: String, + transactionId: Int, + deviceId: String, + publicEncryptionKey: String) async throws -> CapitecPayDetails { + authenticateCallCount += 1 + authenticateSubmitted = (identifier, value, transactionId, deviceId, publicEncryptionKey) + guard let details = expectedDetails else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return details + } + + func requery(transactionReference: String) async throws -> ChargeCardTransaction { + requeryCallCount += 1 + lastRequeryReference = transactionReference + if !expectedRequeryResults.isEmpty { + return expectedRequeryResults.removeFirst() + } + throw expectedErrorResponse ?? MockError.stubNotProvided + } + + func listenForCapitecPayResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction { + listenCallCount += 1 + lastListenedChannel = channelName + if !expectedListenResponses.isEmpty { + return expectedListenResponses.removeFirst() + } + if let error = expectedListenError { + expectedListenError = nil + throw error + } + throw expectedErrorResponse ?? MockError.stubNotProvided + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockQRRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockQRRepository.swift new file mode 100644 index 0000000..1764e44 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockQRRepository.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import PaystackCore +@testable import PaystackUI + +class MockQRRepository: QRRepository { + + var expectedDetails: QRDetails? + var expectedGenerateError: Error? + + var expectedListenResponses: [ChargeCardTransaction] = [] + var expectedListenError: Error? + + var expectedCheckPendingResults: [ChargeCardTransaction] = [] + var expectedCheckPendingError: Error? + + var generateSubmitted: (reference: String, + channelOption: String, + variant: QRVariant) = ("", "", .scanToPay) + private(set) var generateCallCount = 0 + + private(set) var listenCallCount = 0 + private(set) var lastListenedChannel: String? + + private(set) var checkPendingCallCount = 0 + private(set) var lastCheckPendingAccessCode: String? + + func generate(reference: String, + channelOption: String, + variant: QRVariant) async throws -> QRDetails { + generateCallCount += 1 + generateSubmitted = (reference, channelOption, variant) + if let error = expectedGenerateError { + throw error + } + guard let details = expectedDetails else { + throw MockError.stubNotProvided + } + return details + } + + func listenForResponse(onChannel channelName: String) + async throws -> ChargeCardTransaction { + listenCallCount += 1 + lastListenedChannel = channelName + if !expectedListenResponses.isEmpty { + return expectedListenResponses.removeFirst() + } + if let error = expectedListenError { + expectedListenError = nil + throw error + } + throw MockError.stubNotProvided + } + + func checkPending(accessCode: String) async throws -> ChargeCardTransaction { + checkPendingCallCount += 1 + lastCheckPendingAccessCode = accessCode + if !expectedCheckPendingResults.isEmpty { + return expectedCheckPendingResults.removeFirst() + } + if let error = expectedCheckPendingError { + expectedCheckPendingError = nil + throw error + } + throw MockError.stubNotProvided + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/QR/QRChannelDirectoryTests.swift b/Tests/PaystackSDKTests/UI/Charge/QR/QRChannelDirectoryTests.swift new file mode 100644 index 0000000..f74ab84 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/QR/QRChannelDirectoryTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import PaystackUI + +final class QRChannelDirectoryTests: XCTestCase { + + func testMPASSOLTIProducesScanToPayThenSnapScan() { + let entries = QRChannelDirectory.entries( + for: ["MPASS_OLTI"], + transactionId: 5900549926) + + XCTAssertEqual(entries.count, 2) + guard case .scanToPay(let scanConfig) = entries[0] else { + XCTFail("Expected .scanToPay first, got \(entries[0])") + return + } + guard case .snapScan(let snapConfig) = entries[1] else { + XCTFail("Expected .snapScan second, got \(entries[1])") + return + } + XCTAssertEqual(scanConfig.variant, .scanToPay) + XCTAssertEqual(scanConfig.channelOption, "MPASS_OLTI") + XCTAssertEqual(scanConfig.transactionId, 5900549926) + XCTAssertEqual(snapConfig.variant, .snapScan) + XCTAssertEqual(snapConfig.channelOption, "MPASS_OLTI") + XCTAssertEqual(snapConfig.transactionId, 5900549926) + } + + func testUnknownProviderCodeProducesEmpty() { + let entries = QRChannelDirectory.entries( + for: ["FUTURE_PROVIDER_XYZ"], + transactionId: 1234) + + XCTAssertTrue(entries.isEmpty) + } + + func testEmptyOptionsProducesEmpty() { + let entries = QRChannelDirectory.entries( + for: [], + transactionId: 1234) + + XCTAssertTrue(entries.isEmpty) + } + + func testForwardsTransactionIdIntoBothConfigs() { + let entries = QRChannelDirectory.entries( + for: ["MPASS_OLTI"], + transactionId: 424242) + + for entry in entries { + switch entry { + case .scanToPay(let config), .snapScan(let config): + XCTAssertEqual(config.transactionId, 424242) + default: + XCTFail("Unexpected channel type \(entry)") + } + } + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift new file mode 100644 index 0000000..93c2b60 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift @@ -0,0 +1,243 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class QRViewModelTests: XCTestCase { + + var serviceUnderTest: QRViewModel! + var mockChargeContainer: MockChargeContainer! + var mockRepository: MockQRRepository! + + override func setUpWithError() throws { + try super.setUpWithError() + mockChargeContainer = MockChargeContainer() + mockRepository = MockQRRepository() + serviceUnderTest = QRViewModel( + chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .scanToPayExample, + repository: mockRepository) + } + + func testInitialStateIsLoadingQR() { + XCTAssertEqual(serviceUnderTest.state, .loadingQR) + } + + func testVariantExposesConfigVariant() { + XCTAssertEqual(serviceUnderTest.variant, .scanToPay) + } + + func testOnAppearCallsGenerateWithConfigChannelAndTransactionId() async { + mockRepository.expectedDetails = .scanToPayExample + + await serviceUnderTest.onAppear() + + XCTAssertEqual(mockRepository.generateCallCount, 1) + XCTAssertEqual(mockRepository.generateSubmitted.channelOption, "MPASS_OLTI") + XCTAssertEqual(mockRepository.generateSubmitted.reference, + "\(QRConfig.scanToPayExample.transactionId)") + XCTAssertEqual(mockRepository.generateSubmitted.variant, .scanToPay) + } + + func testOnAppearOnSuccessTransitionsToAwaitingScan() async { + mockRepository.expectedDetails = .scanToPayExample + + await serviceUnderTest.onAppear() + + if case .awaitingScan(let details) = serviceUnderTest.state { + XCTAssertEqual(details, .scanToPayExample) + } else { + XCTFail("Expected .awaitingScan, got \(serviceUnderTest.state)") + } + } + + func testOnAppearOnErrorTransitionsToError() async { + let expectedError = PaystackError.response(code: 500, message: "Boom") + mockRepository.expectedGenerateError = expectedError + + await serviceUnderTest.onAppear() + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(error: expectedError))) + } + + func testOnAppearDoesNothingWhenNotInLoadingQRState() async { + mockRepository.expectedDetails = .scanToPayExample + await serviceUnderTest.onAppear() + let stateAfterFirst = serviceUnderTest.state + let callsAfterFirst = mockRepository.generateCallCount + + await serviceUnderTest.onAppear() + + XCTAssertEqual(serviceUnderTest.state, stateAfterFirst) + XCTAssertEqual(mockRepository.generateCallCount, callsAfterFirst) + } + + func testUserTappedICompletedPaymentTransitionsToVerifying() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending) + ] + await serviceUnderTest.onAppear() + + await MainActor.run { + serviceUnderTest.userTappedICompletedPayment() + } + + if case .verifying(let details) = serviceUnderTest.state { + XCTAssertEqual(details, .scanToPayExample) + } else if case .awaitingScan = serviceUnderTest.state { + XCTAssertNotNil(serviceUnderTest.inlineBanner, + "Pending fallback should surface an inline banner") + } else { + XCTFail("Expected .verifying or bounced back to .awaitingScan, got \(serviceUnderTest.state)") + } + } + + func testUserTappedICompletedPaymentOnSuccessRoutesToContainer() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .success) + ] + await serviceUnderTest.onAppear() + + await MainActor.run { + serviceUnderTest.userTappedICompletedPayment() + } + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + XCTAssertEqual(mockRepository.lastCheckPendingAccessCode, + serviceUnderTest.transactionDetails.accessCode) + } + + func testUserTappedICompletedPaymentOnFailedTransitionsToError() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .failed, message: "Bank declined") + ] + await serviceUnderTest.onAppear() + + await MainActor.run { + serviceUnderTest.userTappedICompletedPayment() + } + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testUserTappedICompletedPaymentOnPendingReturnsToAwaitingScanWithBanner() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending) + ] + await serviceUnderTest.onAppear() + + await MainActor.run { + serviceUnderTest.userTappedICompletedPayment() + } + try? await Task.sleep(nanoseconds: 200_000_000) + + if case .awaitingScan = serviceUnderTest.state { + } else { + XCTFail("Expected pending to bounce back to .awaitingScan, got \(serviceUnderTest.state)") + } + XCTAssertEqual(serviceUnderTest.inlineBanner, + QRViewModel.checkPendingFallbackMessage) + } + + @MainActor + func testUserTappedChangePaymentMethodRestartsChannelSelection() { + serviceUnderTest.userTappedChangePaymentMethod() + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + func testProcessTransactionUpdateWithSuccessRoutesToContainer() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .success)) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testProcessTransactionUpdateWithFailedTransitionsToError() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .failed, message: "Bank declined")) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + func testProcessTransactionUpdateWithFailedFallsBackToDefaultMessage() async { + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .failed)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: QRViewModel.failedFallbackMessage))) + } + + func testProcessTransactionUpdateWithNonTerminalStatusDoesNotChangeState() async { + let stateBefore = serviceUnderTest.state + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .pending)) + XCTAssertEqual(serviceUnderTest.state, stateBefore) + } + + // MARK: - Pusher (single-shot, PR QR-E) + + func testOnAppearStartsListenOnReturnedChannel() async { + mockRepository.expectedDetails = .scanToPayExample + + await serviceUnderTest.onAppear() + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertGreaterThanOrEqual(mockRepository.listenCallCount, 1) + XCTAssertEqual(mockRepository.lastListenedChannel, + QRDetails.scanToPayExample.pusherChannel) + } + + func testListenResolvesOnSuccessAndRoutesToContainer() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .success) + ] + let expectation = expectation(description: "container receives success") + mockChargeContainer.onProcessSuccessfulTransaction = { expectation.fulfill() } + + await serviceUnderTest.onAppear() + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testListenResolvesOnFailedStatusToErrorState() async { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .failed, message: "Bank declined") + ] + + await serviceUnderTest.onAppear() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank declined"))) + } + + // MARK: - Retry (PR QR-D) + + func testRetryResetsToLoadingQRAndCallsGenerateAgain() async { + mockRepository.expectedGenerateError = PaystackError.response(code: 500, message: "Boom") + await serviceUnderTest.onAppear() + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(error: PaystackError.response(code: 500, message: "Boom")))) + mockRepository.expectedGenerateError = nil + mockRepository.expectedDetails = .scanToPayExample + + await serviceUnderTest.retry() + + XCTAssertEqual(mockRepository.generateCallCount, 2) + if case .awaitingScan = serviceUnderTest.state { + } else { + XCTFail("Expected .awaitingScan after retry, got \(serviceUnderTest.state)") + } + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift new file mode 100644 index 0000000..20b99fb --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class QRRepositoryImplementationTests: PSTestCase { + + let apiKey = "testsk_Example" + var serviceUnderTest: QRRepositoryImplementation! + var paystack: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + paystack = try PaystackBuilder.newInstance.setKey(apiKey).build() + PaystackContainer.instance.store(paystack) + serviceUnderTest = QRRepositoryImplementation() + } + + func testGenerateHitsCorrectURLAndMethodAndHeaders() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/offline/qr/generate") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "QRGenerateResponse") + + _ = try await serviceUnderTest.generate( + reference: "5900549926", + channelOption: "MPASS_OLTI", + variant: .scanToPay) + } + + func testGenerateMapsResponseIntoScanToPayDetailsWithQRReference() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/offline/qr/generate") + .expectMethod(.post) + .andReturn(json: "QRGenerateResponse") + + let result = try await serviceUnderTest.generate( + reference: "5900549926", + channelOption: "MPASS_OLTI", + variant: .scanToPay) + + XCTAssertEqual(result.qrReference, "1490884538") + XCTAssertEqual(result.pusherChannel, "api_mpass_olti_qr_51826223921246") + } + + func testGenerateMapsResponseIntoSnapScanDetailsWithoutQRReference() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/offline/qr/generate") + .expectMethod(.post) + .andReturn(json: "QRGenerateResponse") + + let result = try await serviceUnderTest.generate( + reference: "5900549926", + channelOption: "MPASS_OLTI", + variant: .snapScan) + + XCTAssertNil(result.qrReference) + XCTAssertEqual(result.pusherChannel, "api_mpass_olti_qr_51826223921246") + } + + func testListenForResponseSubscribesToProvidedChannel() async throws { + let channel = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "QRPusherSuccess") + + let result = try await serviceUnderTest + .listenForResponse(onChannel: channel) + + XCTAssertEqual(result.status, .success) + } + + func testCheckPendingHitsSharedSDKEndpointNotAQRSpecificOne() async throws { + let accessCode = "test_access_code" + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/charge/\(accessCode)") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + _ = try await serviceUnderTest.checkPending(accessCode: accessCode) + } + + func testCheckPendingMapsResponseIntoChargeCardTransaction() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/charge/test_access_code") + .expectMethod(.get) + .andReturn(json: "ChargeAuthenticationResponse") + + let result = try await serviceUnderTest + .checkPending(accessCode: "test_access_code") + + XCTAssertEqual(result.status, .success) + } +}