From 30fed67228c604d474e07e019db8fcb5500043b9 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 23 Jul 2026 08:20:47 +0200 Subject: [PATCH 1/9] 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) + } +} From 06b834e14911f47b8e80e29963ef453de0a04f51 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 27 Aug 2026 13:28:55 +0200 Subject: [PATCH 2/9] Switch Capitec requery to GET with dedicated response model - Add CapitecResponse/CapitecResponseData and ChargeCapitecTransaction so requery no longer reuses the card ChargeResponse shape - Change postRequery from POST with an empty body to a GET - Restore OAEP encryption for Capitec client data and drop the unused PKCS#1 helper from Cryptography - Use the real device fingerprint instead of the hardcoded test value - Set QR generate source to "checkout" and bump bindings version to 2.1.0 --- .../PaystackSDK/API/Charge/CapitecPay.swift | 4 ++-- .../API/Charge/CapitecPayService.swift | 6 +++--- .../Models/Models/Charge/CapitecResponse.swift | 7 +++++++ .../Models/Charge/CapitecResponseData.swift | 5 +++++ .../Core/Models/Models/QRGenerateRequest.swift | 2 +- Sources/PaystackSDK/Core/PaystackConfig.swift | 2 +- .../Core/Utils/Cryptography/Cryptography.swift | 18 ++---------------- .../Repository/CapitecPayRepository.swift | 8 ++++---- .../Viewmodels/CapitecPayViewModel.swift | 11 +++++------ .../Views/CapitecPayIdentifierEntryView.swift | 4 ++-- .../Models/ChargeCapitecTransaction.swift | 15 +++++++++++++++ 11 files changed, 47 insertions(+), 35 deletions(-) create mode 100644 Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponse.swift create mode 100644 Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponseData.swift create mode 100644 Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift diff --git a/Sources/PaystackSDK/API/Charge/CapitecPay.swift b/Sources/PaystackSDK/API/Charge/CapitecPay.swift index faec052..4dfea5b 100644 --- a/Sources/PaystackSDK/API/Charge/CapitecPay.swift +++ b/Sources/PaystackSDK/API/Charge/CapitecPay.swift @@ -32,9 +32,9 @@ public extension Paystack { /// /// - Parameter transactionReference: The `reference` returned from /// `verify_access_code`. - /// - Returns: A ``Service`` carrying a ``ChargeResponse``. + /// - Returns: A ``Service`` carrying a ``CapitecResponse``. func requeryCapitecPay(transactionReference: String) - -> Service { + -> Service { return capitecPayService.postRequery(transactionReference: transactionReference) } diff --git a/Sources/PaystackSDK/API/Charge/CapitecPayService.swift b/Sources/PaystackSDK/API/Charge/CapitecPayService.swift index a7cc1b2..1c7d8e0 100644 --- a/Sources/PaystackSDK/API/Charge/CapitecPayService.swift +++ b/Sources/PaystackSDK/API/Charge/CapitecPayService.swift @@ -4,7 +4,7 @@ protocol CapitecPayService: PaystackService { func postAuthenticate(_ request: CapitecPayAuthenticateRequest) -> Service func postRequery(transactionReference: String) - -> Service + -> Service } struct CapitecPayServiceImplementation: CapitecPayService { @@ -20,8 +20,8 @@ struct CapitecPayServiceImplementation: CapitecPayService { } func postRequery(transactionReference: String) - -> Service { - return post("/requery/\(transactionReference)", EmptyRequest()) + -> Service { + return get("/requery/\(transactionReference)") .asService() } } diff --git a/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponse.swift b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponse.swift new file mode 100644 index 0000000..4464104 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponse.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct CapitecResponse: Codable { + public var status: Bool + public var message: String + public var data: CapitecResponseData +} diff --git a/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponseData.swift b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponseData.swift new file mode 100644 index 0000000..7c4101d --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecResponseData.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct CapitecResponseData: Codable { + public var status: String +} diff --git a/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift b/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift index 2b2e7ef..78e11d9 100644 --- a/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift +++ b/Sources/PaystackSDK/Core/Models/Models/QRGenerateRequest.swift @@ -5,7 +5,7 @@ public struct QRGenerateRequest: Encodable, Equatable { public let reference: String public let channel: String - public init(source: String = "mobile-pos", + public init(source: String = "checkout", reference: String, channel: String) { self.source = source diff --git a/Sources/PaystackSDK/Core/PaystackConfig.swift b/Sources/PaystackSDK/Core/PaystackConfig.swift index b0324c9..51f2cc8 100644 --- a/Sources/PaystackSDK/Core/PaystackConfig.swift +++ b/Sources/PaystackSDK/Core/PaystackConfig.swift @@ -2,5 +2,5 @@ import Foundation public struct PaystackConfig { public var apiKey: String - public var version: String = "2.0" + public var version: String = "2.1.0" } diff --git a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift index 552c244..28ffdc4 100644 --- a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift +++ b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift @@ -1,14 +1,14 @@ import Foundation public protocol CryptographyProtocol { - func encryptPKCS1(text: String, publicKey: String) throws -> String + func encrypt(text: String, publicKey: String) throws -> String } public struct Cryptography: CryptographyProtocol { public init() {} - func encrypt(text: String, publicKey: String) throws -> String { + public func encrypt(text: String, publicKey: String) throws -> String { let key = try createKey(from: publicKey, isPublic: true) var encryptionError: Unmanaged? @@ -27,20 +27,6 @@ public struct Cryptography: CryptographyProtocol { } return try encrypt(text: jsonString, publicKey: publicKey) } - - /// RSA-encrypts `text` with PKCS#1 v1.5 padding and base64-encodes the result. - /// Mirrors Node's `crypto.publicEncrypt` with `constants.RSA_PKCS1_PADDING`. - public func encryptPKCS1(text: String, publicKey: String) throws -> String { - let key = try createKey(from: publicKey, isPublic: true) - - var encryptionError: Unmanaged? - guard let textData = text.data(using: .utf8), - let encryptedData = SecKeyCreateEncryptedData(key, .rsaEncryptionPKCS1, - textData as CFData, &encryptionError) as Data? else { - throw CryptographyError.encryptionFailed - } - return encryptedData.base64EncodedString() - } } // MARK: - Preparing Key diff --git a/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift index 4a4663d..9c1f269 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift @@ -8,7 +8,7 @@ protocol CapitecPayRepository { deviceId: String, publicEncryptionKey: String) async throws -> CapitecPayDetails - func requery(transactionReference: String) async throws -> ChargeCardTransaction + func requery(transactionReference: String) async throws -> ChargeCapitecTransaction func listenForCapitecPayResponse(onChannel channelName: String) async throws -> ChargeCardTransaction @@ -31,7 +31,7 @@ struct CapitecPayRepositoryImplementation: CapitecPayRepository { publicEncryptionKey: String) async throws -> CapitecPayDetails { let plaintext = "\(identifier.rawValue)*\(value)" - let clientdata = try cryptography.encryptPKCS1( + let clientdata = try cryptography.encrypt( text: plaintext, publicKey: publicEncryptionKey) let request = CapitecPayAuthenticateRequest( clientdata: clientdata, @@ -41,10 +41,10 @@ struct CapitecPayRepositoryImplementation: CapitecPayRepository { return CapitecPayDetails.from(response, transactionId: transactionId) } - func requery(transactionReference: String) async throws -> ChargeCardTransaction { + func requery(transactionReference: String) async throws -> ChargeCapitecTransaction { let response = try await paystack .requeryCapitecPay(transactionReference: transactionReference).async() - return ChargeCardTransaction.from(response) + return ChargeCapitecTransaction.from(response) } func listenForCapitecPayResponse(onChannel channelName: String) diff --git a/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift index 5cc8ad5..356d03d 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift @@ -71,8 +71,7 @@ class CapitecPayViewModel: ObservableObject { identifier: identifier, value: value, transactionId: config.transactionId, - //deviceId: deviceFingerprint(), - deviceId: "E403F2353A734C9A871BD0276BF92312", + deviceId: deviceFingerprint(), publicEncryptionKey: config.publicEncryptionKey) state = .awaitingApproval(details) remainingSeconds = details.timeToLive @@ -208,15 +207,15 @@ class CapitecPayViewModel: ObservableObject { @MainActor @discardableResult - private func reactToPollResult(_ result: ChargeCardTransaction) -> Bool { + private func reactToPollResult(_ result: ChargeCapitecTransaction) -> Bool { switch result.status { - case .success: + case "success": cancelAllTasks() chargeContainer.processSuccessfulTransaction(details: transactionDetails) return true - case .failed: + case "failed": cancelAllTasks() - let message = result.message ?? result.displayText ?? Self.failedFallbackMessage + let message = Self.failedFallbackMessage state = .error(ChargeError(message: message)) return true default: diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift index 00ecca0..43dcf82 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift @@ -132,8 +132,8 @@ private struct PreviewCapitecPayRepository: CapitecPayRepository { publicEncryptionKey: String) async throws -> CapitecPayDetails { .example } - func requery(transactionReference: String) async throws -> ChargeCardTransaction { - ChargeCardTransaction(status: .pending) + func requery(transactionReference: String) async throws -> ChargeCapitecTransaction { + ChargeCapitecTransaction(status: "success") } func listenForCapitecPayResponse(onChannel channelName: String) async throws -> ChargeCardTransaction { diff --git a/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift new file mode 100644 index 0000000..10c12bf --- /dev/null +++ b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift @@ -0,0 +1,15 @@ +import Foundation +import PaystackCore + +// TODO: Add further fields here once we know what is required +struct ChargeCapitecTransaction: Equatable { + var status: String +} + +extension ChargeCapitecTransaction { + + static func from(_ response: CapitecResponse) -> Self { + ChargeCapitecTransaction(status: response.data.status) + } + +} From dd9d71035154c3775a73b19bcb97dd0162a53114 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 27 Aug 2026 13:32:38 +0200 Subject: [PATCH 3/9] UI Updates for Dark Mode --- .../CapitecPayAwaitingApprovalView.swift | 26 +++++++------------ .../Views/CapitecPayIdentifierEntryView.swift | 2 +- .../Views/CapitecPayInfoBanner.swift | 6 ++--- .../Charge/QR/Views/QRDisplayView.swift | 18 ++++++------- 4 files changed, 23 insertions(+), 29 deletions(-) diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift index edd2e96..f3dfe7e 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayAwaitingApprovalView.swift @@ -18,7 +18,7 @@ struct CapitecPayAwaitingApprovalView: View { } private var countdownValueColor: Color { - remainingSeconds <= 60 ? .warning02 : .stackGreen + remainingSeconds <= 60 ? .contentWarning : .accentPrimary } var body: some View { @@ -27,7 +27,7 @@ struct CapitecPayAwaitingApprovalView: View { Text("Complete your payment") .font(.heading2) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) .multilineTextAlignment(.center) stepsCard @@ -38,7 +38,7 @@ struct CapitecPayAwaitingApprovalView: View { .buttonStyle(SecondaryButtonStyle()) Button("Change payment method", action: onChangePaymentMethod) - .foregroundColor(.navy02) + .foregroundColor(.contentSecondary) .font(.body14M) .padding(.top, .singlePadding) } @@ -55,7 +55,7 @@ struct CapitecPayAwaitingApprovalView: View { } .padding(.doublePadding) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.gray01.opacity(0.4)) + .background(Color.surfaceInsetTranslucent) .cornerRadius(.cornerRadius) } @@ -63,12 +63,12 @@ struct CapitecPayAwaitingApprovalView: View { private func step(_ prefix: String, bold: String, trailing: String = "") -> some View { HStack(alignment: .top, spacing: .singlePadding) { Circle() - .fill(Color.stackGreen) + .fill(Color.accentPrimary) .frame(width: 6, height: 6) .padding(.top, 8) - (Text(prefix).foregroundColor(.stackBlue) - + Text(bold).foregroundColor(.stackBlue).bold() - + Text(trailing).foregroundColor(.stackBlue)) + (Text(prefix).foregroundColor(.contentPrimary) + + Text(bold).foregroundColor(.contentPrimary).bold() + + Text(trailing).foregroundColor(.contentPrimary)) .font(.body14R) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) @@ -83,20 +83,14 @@ struct CapitecPayAwaitingApprovalView: View { .progressViewStyle(.circular) Text("Confirming payment…") .font(.body14M) - .foregroundColor(.navy02) + .foregroundColor(.contentSecondary) } .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) + .foregroundColor(.contentTertiary) Text(formattedRemaining) .foregroundColor(countdownValueColor) .animation(.easeInOut(duration: 0.2), value: countdownValueColor) diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift index 43dcf82..541a717 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift @@ -22,7 +22,7 @@ struct CapitecPayIdentifierEntryView: View { Text(viewModel.identifier.prompt) .font(.body16M) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) .multilineTextAlignment(.center) .animation(.easeInOut(duration: 0.2), value: viewModel.identifier) diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift index 48ef482..93044b2 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayInfoBanner.swift @@ -8,15 +8,15 @@ struct CapitecPayInfoBanner: View { var body: some View { HStack(alignment: .top, spacing: .singlePadding) { Image(systemName: "info.circle.fill") - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) Text(text) .font(.body14R) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(.doublePadding) - .background(Color.stackBlue.opacity(0.08)) + .background(Color.infoSurface) .cornerRadius(.cornerRadius) } } diff --git a/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift index fac0bc3..758c503 100644 --- a/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift +++ b/Sources/PaystackUI/Charge/QR/Views/QRDisplayView.swift @@ -23,14 +23,14 @@ struct QRDisplayView: View { Text(variant.instructionCopy) .font(.body16M) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) .multilineTextAlignment(.center) qrCodeBlock Text(amount.description) .font(.body16M) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) if variant.showsQRReferenceRow, let reference = details.qrReference { qrReferenceRow(reference: reference) @@ -58,10 +58,10 @@ struct QRDisplayView: View { QRCodeImage(url: details.qrImageURL) .frame(width: 220, height: 220) .padding(.singlePadding) - .background(Color.white) + .background(Color.qrPlate) .overlay( RoundedRectangle(cornerRadius: .cornerRadius) - .stroke(Color.navy05, lineWidth: 1)) + .stroke(Color.borderPrimary, lineWidth: 1)) } private func qrReferenceRow(reference: String) -> some View { @@ -69,9 +69,9 @@ struct QRDisplayView: View { HStack(spacing: .singlePadding) { Text(reference) .font(.body16M) - .foregroundColor(.stackBlue) + .foregroundColor(.contentPrimary) Image(systemName: "doc.on.doc") - .foregroundColor(.navy02) + .foregroundColor(.contentSecondary) .imageScale(.medium) } } @@ -81,11 +81,11 @@ struct QRDisplayView: View { private func inlineBannerView(_ text: String) -> some View { Text(text) .font(.body14M) - .foregroundColor(.warning02) + .foregroundColor(.contentWarning) .multilineTextAlignment(.center) .padding(.singlePadding) .frame(maxWidth: .infinity) - .background(Color.warning02.opacity(0.08)) + .background(Color.warningSurface) .cornerRadius(.cornerRadius) } @@ -95,7 +95,7 @@ struct QRDisplayView: View { .buttonStyle(PrimaryButtonStyle(showLoading: false)) Button("Change payment method", action: onChangePaymentMethod) - .foregroundColor(.navy02) + .foregroundColor(.contentSecondary) .font(.body14M) .padding(.top, .singlePadding) } From c01acea953a129db678ac36274ffbb821c652548 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 27 Aug 2026 13:35:15 +0200 Subject: [PATCH 4/9] DarkMode UI update --- Sources/PaystackUI/Components/CopiedToast.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PaystackUI/Components/CopiedToast.swift b/Sources/PaystackUI/Components/CopiedToast.swift index df8d5be..a486392 100644 --- a/Sources/PaystackUI/Components/CopiedToast.swift +++ b/Sources/PaystackUI/Components/CopiedToast.swift @@ -8,10 +8,10 @@ struct CopiedToast: View { var body: some View { Text(text) .font(.body14M) - .foregroundColor(.white) + .foregroundColor(.contentOnAccent) .padding(.horizontal, .doublePadding) .padding(.vertical, .singlePadding) - .background(Color.stackBlue.opacity(0.9)) + .background(Color.toastSurface) .cornerRadius(.cornerRadius) } } From dfc3820f888f9ee67ca50395d86691051f98a182 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Sun, 6 Sep 2026 19:41:16 +0200 Subject: [PATCH 5/9] Decode Capitec Pay Pusher envelope and requery on listener loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capitec Pay publishes its own event shape rather than the flat Charge3DSResponse used by card 3-D Secure, mobile money, Zap, QR and bank transfer: the top-level `status` is a Bool and the transaction status is nested at `data.status`. Decoding it as Charge3DSResponse threw, and because PusherSubscriptionListener is single-shot, that decode failure burned the subscription — the customer then sat on the approval screen until the countdown expired. - Add CapitecPusherResponse / CapitecPusherResponseData with every field below `status` optional, so a partial envelope still decodes instead of dropping the subscription. listenForCapitecPayResponse now returns it. - Route both resolution paths (Pusher event and requery) through ChargeCapitecTransaction so terminal status is interpreted in one place; status is trimmed + lowercased, and `status: false` maps to a failure regardless of `data`. Carry the server message through as the failure reason instead of always using the fallback copy. - On a Pusher await failure, start the requery loop immediately rather than waiting out the countdown, leaving `state` untouched so the approval steps stay on screen. Guard startRequeryLoop so the first of its two entry points owns the loop. - Add CapitecPusherPending / CapitecRequeryResponse fixtures (registered in Package.swift) and ChargeCapitecTransactionTests; extend the API, repository and view-model tests to cover the new envelope and fallback. --- Package.swift | 2 + .../PaystackSDK/API/Charge/CapitecPay.swift | 18 +- .../Models/Charge/CapitecPusherResponse.swift | 53 ++++++ .../Repository/CapitecPayRepository.swift | 6 +- .../Viewmodels/CapitecPayViewModel.swift | 46 +++-- .../Views/CapitecPayIdentifierEntryView.swift | 6 +- .../Models/ChargeCapitecTransaction.swift | 39 ++++- .../API/Charge/CapitecPayTests.swift | 55 +++++- .../Resources/CapitecPayPusherFailed.json | 6 +- .../Resources/CapitecPayPusherPending.json | 6 + .../Resources/CapitecPayPusherSuccess.json | 12 +- .../Resources/CapitecRequeryResponse.json | 7 + .../CapitecPay/CapitecPayViewModelTests.swift | 158 +++++++++++++++--- .../ChargeCapitecTransactionTests.swift | 69 ++++++++ ...itecPayRepositoryImplementationTests.swift | 60 ++++++- .../Mocks/MockCapitecPayRepository.swift | 8 +- 16 files changed, 464 insertions(+), 87 deletions(-) create mode 100644 Sources/PaystackSDK/Core/Models/Models/Charge/CapitecPusherResponse.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherPending.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/CapitecRequeryResponse.json create mode 100644 Tests/PaystackSDKTests/UI/Charge/CapitecPay/ChargeCapitecTransactionTests.swift diff --git a/Package.swift b/Package.swift index 1943515..d213429 100644 --- a/Package.swift +++ b/Package.swift @@ -57,6 +57,8 @@ let package = Package( .copy("API/Charge/Resources/ZapPusherFailed.json"), .copy("API/Charge/Resources/CapitecPayPusherSuccess.json"), .copy("API/Charge/Resources/CapitecPayPusherFailed.json"), + .copy("API/Charge/Resources/CapitecPayPusherPending.json"), + .copy("API/Charge/Resources/CapitecRequeryResponse.json"), .copy("API/Charge/Resources/QRPusherSuccess.json"), .copy("API/Charge/Resources/QRPusherFailed.json") diff --git a/Sources/PaystackSDK/API/Charge/CapitecPay.swift b/Sources/PaystackSDK/API/Charge/CapitecPay.swift index 4dfea5b..e8dad4f 100644 --- a/Sources/PaystackSDK/API/Charge/CapitecPay.swift +++ b/Sources/PaystackSDK/API/Charge/CapitecPay.swift @@ -39,21 +39,25 @@ public extension Paystack { } /// 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. + /// returned by ``authenticateCapitecPay(_:)``. + /// + /// Capitec Pay publishes its own envelope rather than the flat + /// ``Charge3DSResponse`` shape used by card 3-D Secure, mobile money, + /// Zap, QR and bank transfer — the top-level `status` is a `Bool` and + /// the transaction status is nested at `data.status`. See + /// ``CapitecPusherResponse``. /// /// The underlying listener is single-shot per the existing /// `PusherSubscriptionListener` contract — one event resolves the - /// listener. + /// listener. Non-terminal events are therefore not expected here; the + /// requery loop is what resolves anything this channel does not. /// /// - Parameter channelName: The `CAPITECPAY_{transactionId}` channel /// for this transaction. - /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// - Returns: A ``Service`` carrying a ``CapitecPusherResponse`` on the /// first event the channel emits. func listenForCapitecPayResponse(onChannel channelName: String) - -> Service { + -> Service { let subscription: any Subscription = PusherSubscription( channelName: channelName, eventName: "response") return Service(subscription) diff --git a/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecPusherResponse.swift b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecPusherResponse.swift new file mode 100644 index 0000000..014e9c1 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/Charge/CapitecPusherResponse.swift @@ -0,0 +1,53 @@ +import Foundation + +/// The event published on the Capitec Pay Pusher channel +/// (`CAPITECPAY_{transactionId}`, event `response`). +/// +/// Capitec Pay does **not** use the flat ``Charge3DSResponse`` shape shared by +/// card 3-D Secure, mobile money, Zap, QR and bank transfer. The top-level +/// `status` is a `Bool` and the transaction status is nested under `data`: +/// +/// ```json +/// { +/// "status": true, +/// "type": "success", +/// "code": "ok", +/// "data": { "status": "success" }, +/// "message": "Charge successful" +/// } +/// ``` +/// +/// A failure is signalled by `status: false`. Everything below `status` is +/// optional — the backend does not guarantee `data` on every event — so a +/// partial envelope still decodes rather than throwing and dropping the +/// single-shot subscription on the floor. +public struct CapitecPusherResponse: Decodable, Equatable { + /// `true` for a successful charge, `false` for a terminal failure. + public var status: Bool + public var type: String? + public var code: String? + public var message: String? + /// Absent on some events — never force-unwrap. + public var data: CapitecPusherResponseData? + + public init(status: Bool, + type: String? = nil, + code: String? = nil, + message: String? = nil, + data: CapitecPusherResponseData? = nil) { + self.status = status + self.type = type + self.code = code + self.message = message + self.data = data + } +} + +public struct CapitecPusherResponseData: Decodable, Equatable { + /// `"success"` / `"failed"` — the transaction status proper. + public var status: String? + + public init(status: String? = nil) { + self.status = status + } +} diff --git a/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift index 9c1f269..19aa103 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Repository/CapitecPayRepository.swift @@ -11,7 +11,7 @@ protocol CapitecPayRepository { func requery(transactionReference: String) async throws -> ChargeCapitecTransaction func listenForCapitecPayResponse(onChannel channelName: String) - async throws -> ChargeCardTransaction + async throws -> ChargeCapitecTransaction } struct CapitecPayRepositoryImplementation: CapitecPayRepository { @@ -48,9 +48,9 @@ struct CapitecPayRepositoryImplementation: CapitecPayRepository { } func listenForCapitecPayResponse(onChannel channelName: String) - async throws -> ChargeCardTransaction { + async throws -> ChargeCapitecTransaction { let response = try await paystack .listenForCapitecPayResponse(onChannel: channelName).async() - return ChargeCardTransaction.from(response) + return ChargeCapitecTransaction.from(response) } } diff --git a/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift index 356d03d..4721815 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Viewmodels/CapitecPayViewModel.swift @@ -154,31 +154,35 @@ class CapitecPayViewModel: ObservableObject { do { let update = try await repository .listenForCapitecPayResponse(onChannel: channel) - await processTransactionUpdate(update) + await reactToPollResult(update) } catch { - Logger.error("Capitec Pay Pusher await failed: %@", + // The listener is single-shot: a decode failure or a socket error + // burns the subscription, so no second event will arrive. Start + // polling now rather than waiting out the approval countdown. + Logger.error("Capitec Pay Pusher await failed, falling back to requery: %@", arguments: error.localizedDescription) + await beginPusherFallbackRequery() } } + /// Starts the requery loop underneath the approval screen after the Pusher + /// subscription has been lost. Deliberately leaves `state` alone — the + /// customer still needs the countdown and the in-app approval steps on + /// screen; the countdown expiring moves them to `.requerying` as usual. @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)) + private func beginPusherFallbackRequery() { + switch state { + case .awaitingApproval, .requerying: + startRequeryLoop() default: - Logger.info("Capitec Pay: non-terminal transaction status %@", - arguments: String(describing: update.status)) + return } } private func startRequeryLoop() { - requeryLoopTask?.cancel() + // Two entry points (countdown expiry and the Pusher fallback) can both + // reach here — the first one to start owns the loop. + guard requeryLoopTask == nil else { return } requeryLoopTask = Task { [weak self] in guard let self else { return } let maxIterations = Self.requeryMaxIterations @@ -205,20 +209,26 @@ class CapitecPayViewModel: ObservableObject { } } + /// The single place a Capitec Pay terminal status is interpreted — both the + /// Pusher event and the requery response arrive here. + /// + /// - Returns: `true` when the transaction reached a terminal state. @MainActor @discardableResult - private func reactToPollResult(_ result: ChargeCapitecTransaction) -> Bool { + func reactToPollResult(_ result: ChargeCapitecTransaction) -> Bool { switch result.status { - case "success": + case CapitecTransactionStatus.success: cancelAllTasks() chargeContainer.processSuccessfulTransaction(details: transactionDetails) return true - case "failed": + case CapitecTransactionStatus.failed: cancelAllTasks() - let message = Self.failedFallbackMessage + let message = result.message ?? Self.failedFallbackMessage state = .error(ChargeError(message: message)) return true default: + Logger.info("Capitec Pay: non-terminal transaction status %@", + arguments: result.status) return false } } diff --git a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift index 541a717..a9486e8 100644 --- a/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift +++ b/Sources/PaystackUI/Charge/CapitecPay/Views/CapitecPayIdentifierEntryView.swift @@ -133,10 +133,10 @@ private struct PreviewCapitecPayRepository: CapitecPayRepository { .example } func requery(transactionReference: String) async throws -> ChargeCapitecTransaction { - ChargeCapitecTransaction(status: "success") + ChargeCapitecTransaction(status: "success", message: nil) } func listenForCapitecPayResponse(onChannel channelName: String) - async throws -> ChargeCardTransaction { - ChargeCardTransaction(status: .success) + async throws -> ChargeCapitecTransaction { + ChargeCapitecTransaction(status: "success", message: nil) } } diff --git a/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift index 10c12bf..8ec8811 100644 --- a/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift +++ b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCapitecTransaction.swift @@ -1,15 +1,50 @@ import Foundation import PaystackCore -// TODO: Add further fields here once we know what is required +/// The normalised terminal-status view of a Capitec Pay transaction. +/// +/// Both resolution paths — the Pusher event and the requery endpoint — map +/// onto this type, so `CapitecPayViewModel` has a single place where a +/// terminal status is interpreted. struct ChargeCapitecTransaction: Equatable { + /// Lowercased transaction status, e.g. `"success"` / `"failed"`. + /// Empty when the payload carried nothing terminal. var status: String + /// Server-supplied message, used as the failure reason when present. + var message: String? } extension ChargeCapitecTransaction { static func from(_ response: CapitecResponse) -> Self { - ChargeCapitecTransaction(status: response.data.status) + ChargeCapitecTransaction(status: normalise(response.data.status), + message: response.message) } + /// Maps the Capitec Pay Pusher envelope. + /// + /// - `status: false` is a terminal failure regardless of what `data` holds. + /// - Otherwise the transaction status comes from `data.status`. + /// - `data` is not guaranteed: with `status: true` and no `data` there is + /// nothing terminal to act on, so this maps to an empty status and the + /// requery loop resolves the transaction instead. + static func from(_ response: CapitecPusherResponse) -> Self { + guard response.status else { + return ChargeCapitecTransaction(status: CapitecTransactionStatus.failed, + message: response.message) + } + return ChargeCapitecTransaction(status: normalise(response.data?.status), + message: response.message) + } + + private static func normalise(_ status: String?) -> String { + guard let status else { return "" } + return status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +/// The terminal status values `CapitecPayViewModel` acts on. +enum CapitecTransactionStatus { + static let success = "success" + static let failed = "failed" } diff --git a/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift b/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift index 4c67333..740f10d 100644 --- a/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/CapitecPayTests.swift @@ -55,30 +55,33 @@ final class CapitecPayTests: PSTestCase { func testRequeryCapitecPayHitsCorrectURLWithTransactionReferenceInPath() async throws { mockServiceExecutor .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") - .expectMethod(.post) + .expectMethod(.get) .expectHeader("Authorization", "Bearer \(apiKey)") - .andReturn(json: "ChargeAuthenticationResponse") + .andReturn(json: "CapitecRequeryResponse") _ = try await serviceUnderTest .requeryCapitecPay(transactionReference: "T_ref_5900549926") .async() } - func testRequeryCapitecPayDecodesChargeResponseShape() async throws { + func testRequeryCapitecPayDecodesCapitecResponseShape() async throws { mockServiceExecutor .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") - .expectMethod(.post) + .expectMethod(.get) .expectHeader("Authorization", "Bearer \(apiKey)") - .andReturn(json: "ChargeAuthenticationResponse") + .andReturn(json: "CapitecRequeryResponse") let result = try await serviceUnderTest .requeryCapitecPay(transactionReference: "T_ref_5900549926") .async() XCTAssertEqual(result.status, true) - XCTAssertEqual(result.data.reference, "36xz3b9rie9ppvz") + XCTAssertEqual(result.message, "Charge successful") + XCTAssertEqual(result.data.status, "success") } + // MARK: - Pusher envelope + func testListenForCapitecPayResponseSubscribesToProvidedChannel() async throws { let channelName = "CAPITECPAY_5900549926" mockSubscriptionListener @@ -88,10 +91,14 @@ final class CapitecPayTests: PSTestCase { let result = try await serviceUnderTest .listenForCapitecPayResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, .success) + XCTAssertEqual(result.status, true) + XCTAssertEqual(result.type, "success") + XCTAssertEqual(result.code, "ok") + XCTAssertEqual(result.message, "Charge successful") + XCTAssertEqual(result.data?.status, "success") } - func testListenForCapitecPayResponseDecodesFailedShape() async throws { + func testListenForCapitecPayResponseDecodesFailedShapeWithoutData() async throws { let channelName = "CAPITECPAY_5900549926" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) @@ -100,7 +107,37 @@ final class CapitecPayTests: PSTestCase { let result = try await serviceUnderTest .listenForCapitecPayResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.status, false) XCTAssertEqual(result.message, "Bank declined") + XCTAssertNil(result.data) + } + + func testListenForCapitecPayResponseDecodesEnvelopeWithMissingData() async throws { + let channelName = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherPending") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, true) + XCTAssertNil(result.data) + } + + func testListenForCapitecPayResponseDecodesMinimalEnvelope() async throws { + let channelName = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString("{ \"status\": true }") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, true) + XCTAssertNil(result.type) + XCTAssertNil(result.code) + XCTAssertNil(result.message) + XCTAssertNil(result.data) } } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json index 390b7bf..9f1ecd2 100644 --- a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json @@ -1,6 +1,6 @@ { - "status": "failed", - "trans": "5900549926", - "trxref": "T_ref_5900549926", + "status": false, + "type": "error", + "code": "failed", "message": "Bank declined" } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherPending.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherPending.json new file mode 100644 index 0000000..67244f2 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherPending.json @@ -0,0 +1,6 @@ +{ + "status": true, + "type": "success", + "code": "ok", + "message": "Charge pending" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json index cc8acc0..078f0cf 100644 --- a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json @@ -1,7 +1,9 @@ { - "status": "success", - "trans": "5900549926", - "trxref": "T_ref_5900549926", - "reference": "T_ref_5900549926", - "message": "Payment approved" + "status": true, + "type": "success", + "code": "ok", + "data": { + "status": "success" + }, + "message": "Charge successful" } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecRequeryResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecRequeryResponse.json new file mode 100644 index 0000000..ea4ff5e --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecRequeryResponse.json @@ -0,0 +1,7 @@ +{ + "status": true, + "message": "Charge successful", + "data": { + "status": "success" + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift index 2335c5a..93addb8 100644 --- a/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/CapitecPayViewModelTests.swift @@ -120,38 +120,64 @@ final class CapitecPayViewModelTests: XCTestCase { .error(ChargeError(error: expectedError))) } - func testProcessTransactionUpdateWithSuccessRoutesToContainer() async { - await serviceUnderTest.processTransactionUpdate( - ChargeCardTransaction(status: .success)) + // MARK: - Terminal status handling + + @MainActor + func testReactToPollResultWithSuccessRoutesToContainer() { + let resolved = serviceUnderTest.reactToPollResult( + ChargeCapitecTransaction(status: "success", message: "Charge successful")) + + XCTAssertTrue(resolved) XCTAssertTrue(mockChargeContainer.transactionSuccessful) } - func testProcessTransactionUpdateWithFailedTransitionsToError() async { - await serviceUnderTest.processTransactionUpdate( - ChargeCardTransaction(status: .failed, message: "Bank declined")) + @MainActor + func testReactToPollResultWithFailedTransitionsToErrorWithServerMessage() { + let resolved = serviceUnderTest.reactToPollResult( + ChargeCapitecTransaction(status: "failed", message: "Bank declined")) + XCTAssertTrue(resolved) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: "Bank declined"))) } - func testProcessTransactionUpdateWithFailedFallsBackToDefaultMessage() async { - await serviceUnderTest.processTransactionUpdate( - ChargeCardTransaction(status: .failed)) + @MainActor + func testReactToPollResultWithFailedFallsBackToDefaultMessage() { + let resolved = serviceUnderTest.reactToPollResult( + ChargeCapitecTransaction(status: "failed", message: nil)) + XCTAssertTrue(resolved) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: CapitecPayViewModel.failedFallbackMessage))) } - func testProcessTransactionUpdateWithNonTerminalStatusDoesNotChangeState() async { + @MainActor + func testReactToPollResultWithNonTerminalStatusDoesNotChangeState() { + let stateBefore = serviceUnderTest.state + + let resolved = serviceUnderTest.reactToPollResult( + ChargeCapitecTransaction(status: "pending", message: nil)) + + XCTAssertFalse(resolved) + XCTAssertEqual(serviceUnderTest.state, stateBefore) + } + + /// A Pusher envelope with `status: true` and no `data` maps to an empty + /// status — nothing terminal to act on, requery resolves it instead. + @MainActor + func testReactToPollResultWithEmptyStatusDoesNotChangeState() { let stateBefore = serviceUnderTest.state - await serviceUnderTest.processTransactionUpdate( - ChargeCardTransaction(status: .pending)) + + let resolved = serviceUnderTest.reactToPollResult( + ChargeCapitecTransaction(status: "", message: "Charge pending")) + + XCTAssertFalse(resolved) XCTAssertEqual(serviceUnderTest.state, stateBefore) } func testUserTappedIveApprovedThePaymentFiresOneRequery() async { mockRepository.expectedRequeryResults = [ - ChargeCardTransaction(status: .pending) + ChargeCapitecTransaction(status: "pending", message: nil) ] await MainActor.run { @@ -166,7 +192,7 @@ final class CapitecPayViewModelTests: XCTestCase { func testUserTappedIveApprovedWithSuccessRoutesToContainer() async { mockRepository.expectedRequeryResults = [ - ChargeCardTransaction(status: .success) + ChargeCapitecTransaction(status: "success", message: nil) ] await MainActor.run { @@ -207,7 +233,7 @@ final class CapitecPayViewModelTests: XCTestCase { func testListenResolvesOnSuccessAndRoutesToContainer() async { mockRepository.expectedDetails = .example mockRepository.expectedListenResponses = [ - ChargeCardTransaction(status: .success) + ChargeCapitecTransaction(status: "success", message: nil) ] let expectation = expectation(description: "container receives success") mockChargeContainer.onProcessSuccessfulTransaction = { expectation.fulfill() } @@ -223,7 +249,7 @@ final class CapitecPayViewModelTests: XCTestCase { func testListenResolvesOnFailedStatusToErrorState() async { mockRepository.expectedDetails = .example mockRepository.expectedListenResponses = [ - ChargeCardTransaction(status: .failed, message: "Bank declined") + ChargeCapitecTransaction(status: "failed", message: "Bank declined") ] serviceUnderTest.identifier = .cellphone serviceUnderTest.value = "0609603632" @@ -235,6 +261,76 @@ final class CapitecPayViewModelTests: XCTestCase { .error(ChargeError(message: "Bank declined"))) } + // MARK: - Pusher failure degrades to requery polling + + func testListenFailureStartsRequeryPollingWithoutChangingState() async throws { + mockRepository.expectedDetails = .example + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedRequeryResults = [ + ChargeCapitecTransaction(status: "pending", message: nil) + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 5 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 1_600_000_000) + + XCTAssertGreaterThanOrEqual(mockRepository.requeryCallCount, 1) + // The customer keeps the countdown + in-app approval steps on screen. + if case .awaitingApproval = serviceUnderTest.state { + // ok + } else { + XCTFail("Expected .awaitingApproval, got \(serviceUnderTest.state)") + } + } + + func testListenFailureFallbackRequeryResolvesSuccess() async throws { + mockRepository.expectedDetails = .example + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedRequeryResults = [ + ChargeCapitecTransaction(status: "success", message: nil) + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 5 + 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: 3.0) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + /// The countdown expiring must not start a second, overlapping loop on top + /// of the one the Pusher fallback already started. + func testFallbackAndCountdownDoNotStartOverlappingRequeryLoops() async throws { + mockRepository.expectedDetails = CapitecPayDetails( + timeToLive: 1, + expiryDate: Date().addingTimeInterval(1), + pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedRequeryResults = [ + ChargeCapitecTransaction(status: "pending", message: nil), + ChargeCapitecTransaction(status: "pending", message: nil), + ChargeCapitecTransaction(status: "pending", message: nil) + ] + CapitecPayViewModel.requeryPollIntervalSeconds = 1 + CapitecPayViewModel.requeryMaxIterations = 5 + serviceUnderTest.identifier = .cellphone + serviceUnderTest.value = "0609603632" + + await serviceUnderTest.submitIdentifier() + try await Task.sleep(nanoseconds: 2_600_000_000) + + // One loop at ~1s intervals over ~2.6s — two or three polls, not double. + XCTAssertLessThanOrEqual(mockRepository.requeryCallCount, 3) + XCTAssertGreaterThanOrEqual(mockRepository.requeryCallCount, 1) + } + // MARK: - Countdown + requery loop (PR CP-E / CP-F) func testCountdownExpiryTransitionsToRequerying() async throws { @@ -242,8 +338,14 @@ final class CapitecPayViewModelTests: XCTestCase { timeToLive: 1, expiryDate: Date().addingTimeInterval(1), pusherChannel: "CAPITECPAY_5900549926") + // Non-terminal Pusher event: resolves the single-shot listener without + // erroring, so the countdown — not the failure fallback — is what + // starts the requery loop here. + mockRepository.expectedListenResponses = [ + ChargeCapitecTransaction(status: "pending", message: nil) + ] CapitecPayViewModel.requeryPollIntervalSeconds = 100 - CapitecPayViewModel.requeryMaxIterations = 0 + CapitecPayViewModel.requeryMaxIterations = 5 serviceUnderTest.identifier = .cellphone serviceUnderTest.value = "0609603632" @@ -252,11 +354,8 @@ final class CapitecPayViewModelTests: XCTestCase { 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)") + XCTFail("Expected .requerying, got \(serviceUnderTest.state)") } } @@ -265,8 +364,11 @@ final class CapitecPayViewModelTests: XCTestCase { timeToLive: 1, expiryDate: Date().addingTimeInterval(1), pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedListenResponses = [ + ChargeCapitecTransaction(status: "pending", message: nil) + ] mockRepository.expectedRequeryResults = [ - ChargeCardTransaction(status: .success) + ChargeCapitecTransaction(status: "success", message: nil) ] CapitecPayViewModel.requeryPollIntervalSeconds = 1 CapitecPayViewModel.requeryMaxIterations = 5 @@ -284,8 +386,11 @@ final class CapitecPayViewModelTests: XCTestCase { timeToLive: 1, expiryDate: Date().addingTimeInterval(1), pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedListenResponses = [ + ChargeCapitecTransaction(status: "pending", message: nil) + ] mockRepository.expectedRequeryResults = [ - ChargeCardTransaction(status: .failed, displayText: nil, message: "Bank declined") + ChargeCapitecTransaction(status: "failed", message: "Bank declined") ] CapitecPayViewModel.requeryPollIntervalSeconds = 1 CapitecPayViewModel.requeryMaxIterations = 5 @@ -304,9 +409,12 @@ final class CapitecPayViewModelTests: XCTestCase { timeToLive: 1, expiryDate: Date().addingTimeInterval(1), pusherChannel: "CAPITECPAY_5900549926") + mockRepository.expectedListenResponses = [ + ChargeCapitecTransaction(status: "pending", message: nil) + ] mockRepository.expectedRequeryResults = [ - ChargeCardTransaction(status: .pending), - ChargeCardTransaction(status: .pending) + ChargeCapitecTransaction(status: "pending", message: nil), + ChargeCapitecTransaction(status: "pending", message: nil) ] CapitecPayViewModel.requeryPollIntervalSeconds = 1 CapitecPayViewModel.requeryMaxIterations = 2 diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPay/ChargeCapitecTransactionTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/ChargeCapitecTransactionTests.swift new file mode 100644 index 0000000..90dc6a9 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPay/ChargeCapitecTransactionTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class ChargeCapitecTransactionTests: XCTestCase { + + // MARK: - Pusher envelope + + func testFromPusherResponseMapsNestedDataStatus() { + let response = CapitecPusherResponse( + status: true, + type: "success", + code: "ok", + message: "Charge successful", + data: CapitecPusherResponseData(status: "success")) + + XCTAssertEqual(ChargeCapitecTransaction.from(response), + ChargeCapitecTransaction(status: "success", + message: "Charge successful")) + } + + func testFromPusherResponseMapsFalseStatusToFailedRegardlessOfData() { + let response = CapitecPusherResponse( + status: false, + message: "Bank declined", + data: nil) + + XCTAssertEqual(ChargeCapitecTransaction.from(response), + ChargeCapitecTransaction(status: "failed", + message: "Bank declined")) + } + + func testFromPusherResponseWithFalseStatusIgnoresContradictoryData() { + let response = CapitecPusherResponse( + status: false, + message: "Charge failed", + data: CapitecPusherResponseData(status: "success")) + + XCTAssertEqual(ChargeCapitecTransaction.from(response).status, "failed") + } + + func testFromPusherResponseWithMissingDataIsNonTerminal() { + let response = CapitecPusherResponse(status: true, + message: "Charge pending", + data: nil) + + XCTAssertEqual(ChargeCapitecTransaction.from(response).status, "") + } + + func testFromPusherResponseNormalisesCasingAndWhitespace() { + let response = CapitecPusherResponse( + status: true, + data: CapitecPusherResponseData(status: " SUCCESS ")) + + XCTAssertEqual(ChargeCapitecTransaction.from(response).status, "success") + } + + // MARK: - Requery envelope + + func testFromRequeryResponseMapsStatusAndMessage() { + let response = CapitecResponse(status: true, + message: "Charge successful", + data: CapitecResponseData(status: "success")) + + XCTAssertEqual(ChargeCapitecTransaction.from(response), + ChargeCapitecTransaction(status: "success", + message: "Charge successful")) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift index f602cda..a22b38a 100644 --- a/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/CapitecPayRepository/CapitecPayRepositoryImplementationTests.swift @@ -51,26 +51,29 @@ final class CapitecPayRepositoryImplementationTests: PSTestCase { func testRequeryHitsCorrectURLWithTransactionReferenceInPath() async throws { mockServiceExecutor .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") - .expectMethod(.post) + .expectMethod(.get) .expectHeader("Authorization", "Bearer \(apiKey)") - .andReturn(json: "ChargeAuthenticationResponse") + .andReturn(json: "CapitecRequeryResponse") _ = try await serviceUnderTest.requery( transactionReference: "T_ref_5900549926") } - func testRequeryMapsResponseIntoChargeCardTransaction() async throws { + func testRequeryMapsResponseIntoChargeCapitecTransaction() async throws { mockServiceExecutor .expectURL("https://api.paystack.co/capitec-pay/requery/T_ref_5900549926") - .expectMethod(.post) - .andReturn(json: "ChargeAuthenticationResponse") + .expectMethod(.get) + .andReturn(json: "CapitecRequeryResponse") let result = try await serviceUnderTest.requery( transactionReference: "T_ref_5900549926") - XCTAssertEqual(result.status, .success) + XCTAssertEqual(result, ChargeCapitecTransaction(status: "success", + message: "Charge successful")) } + // MARK: - Pusher + func testListenForCapitecPayResponseSubscribesToProvidedChannel() async throws { let channel = "CAPITECPAY_5900549926" mockSubscriptionListener @@ -80,12 +83,53 @@ final class CapitecPayRepositoryImplementationTests: PSTestCase { let result = try await serviceUnderTest .listenForCapitecPayResponse(onChannel: channel) - XCTAssertEqual(result.status, .success) + XCTAssertEqual(result, ChargeCapitecTransaction(status: "success", + message: "Charge successful")) + } + + func testListenForCapitecPayResponseMapsFailedEnvelopeWithoutData() async throws { + let channel = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherFailed") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channel) + + XCTAssertEqual(result, ChargeCapitecTransaction(status: "failed", + message: "Bank declined")) + } + + func testListenForCapitecPayResponseMapsMissingDataToNonTerminalStatus() async throws { + let channel = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "CapitecPayPusherPending") + + let result = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channel) + + XCTAssertEqual(result.status, "") + } + + func testListenForCapitecPayResponseThrowsOnUndecodablePayload() async { + let channel = "CAPITECPAY_5900549926" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString("not json at all") + + do { + _ = try await serviceUnderTest + .listenForCapitecPayResponse(onChannel: channel) + XCTFail("Expected a decoding error") + } catch { + // expected — the view model degrades to requery polling from here + } } } private struct FakeCryptography: CryptographyProtocol { - func encryptPKCS1(text: String, publicKey: String) throws -> String { + func encrypt(text: String, publicKey: String) throws -> String { return "fake-encrypted:\(text)" } } diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift index 864a68d..6dafc2e 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockCapitecPayRepository.swift @@ -5,10 +5,10 @@ import Foundation class MockCapitecPayRepository: CapitecPayRepository { var expectedDetails: CapitecPayDetails? - var expectedRequeryResults: [ChargeCardTransaction] = [] + var expectedRequeryResults: [ChargeCapitecTransaction] = [] var expectedErrorResponse: Error? - var expectedListenResponses: [ChargeCardTransaction] = [] + var expectedListenResponses: [ChargeCapitecTransaction] = [] var expectedListenError: Error? var authenticateSubmitted: (identifier: CapitecPayIdentifier, @@ -37,7 +37,7 @@ class MockCapitecPayRepository: CapitecPayRepository { return details } - func requery(transactionReference: String) async throws -> ChargeCardTransaction { + func requery(transactionReference: String) async throws -> ChargeCapitecTransaction { requeryCallCount += 1 lastRequeryReference = transactionReference if !expectedRequeryResults.isEmpty { @@ -47,7 +47,7 @@ class MockCapitecPayRepository: CapitecPayRepository { } func listenForCapitecPayResponse(onChannel channelName: String) - async throws -> ChargeCardTransaction { + async throws -> ChargeCapitecTransaction { listenCallCount += 1 lastListenedChannel = channelName if !expectedListenResponses.isEmpty { From bd72e3d380b2b34f62a0c1bd1e3e46082eea7c19 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 7 Sep 2026 09:19:43 +0200 Subject: [PATCH 6/9] Fix tests left stale by the OAEP/QR source changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 06b834e dropped Cryptography.encryptPKCS1 and reverted the protocol to encrypt(text:publicKey:), and changed the QR generate source default to "checkout", but left the tests calling the removed method — the test target no longer compiled. - Drop the four PKCS#1 tests and the decryptPKCS1 helper from CryptographyTests; each had an exact OAEP twin in the same file. - Rename CryptographyEncryptPKCS1Tests to CryptographyEncryptIdentifierTests and move it onto encrypt/OAEP-SHA1, keeping its unique coverage: a generated 2048-bit key pair, the Capitec clientdata plaintext shapes, and non-deterministic ciphertext. - Update the QR default-source test to expect "checkout". --- .../PaystackSDKTests/API/Charge/QRTests.swift | 4 +- ... CryptographyEncryptIdentifierTests.swift} | 29 ++++++----- .../Core/CryptographyTests.swift | 51 ------------------- 3 files changed, 18 insertions(+), 66 deletions(-) rename Tests/PaystackSDKTests/Core/{CryptographyEncryptPKCS1Tests.swift => CryptographyEncryptIdentifierTests.swift} (70%) diff --git a/Tests/PaystackSDKTests/API/Charge/QRTests.swift b/Tests/PaystackSDKTests/API/Charge/QRTests.swift index 3cd70ae..f3ecb49 100644 --- a/Tests/PaystackSDKTests/API/Charge/QRTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/QRTests.swift @@ -48,11 +48,11 @@ final class QRTests: PSTestCase { XCTAssertTrue(result.data.url.hasPrefix("https://s3.eu-west-1.amazonaws.com/")) } - func testGenerateQRDefaultsSourceToMobilePos() { + func testGenerateQRDefaultsSourceToCheckout() { let request = QRGenerateRequest( reference: "T_ref_5900549926", channel: "MPASS_OLTI") - XCTAssertEqual(request.source, "mobile-pos") + XCTAssertEqual(request.source, "checkout") } func testListenForQRResponseSubscribesToProvidedChannel() async throws { diff --git a/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift b/Tests/PaystackSDKTests/Core/CryptographyEncryptIdentifierTests.swift similarity index 70% rename from Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift rename to Tests/PaystackSDKTests/Core/CryptographyEncryptIdentifierTests.swift index 20b6308..0573f8f 100644 --- a/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift +++ b/Tests/PaystackSDKTests/Core/CryptographyEncryptIdentifierTests.swift @@ -1,7 +1,10 @@ import XCTest @testable import PaystackCore -final class CryptographyEncryptPKCS1Tests: XCTestCase { +/// Covers `Cryptography.encrypt(text:publicKey:)` against a freshly generated +/// 2048-bit key pair, using the identifier plaintext shapes Capitec Pay sends +/// as `clientdata`. +final class CryptographyEncryptIdentifierTests: XCTestCase { private var privateKey: SecKey! private var publicKeyBase64: String! @@ -28,40 +31,40 @@ final class CryptographyEncryptPKCS1Tests: XCTestCase { publicKeyBase64 = publicKeyData.base64EncodedString() } - func testEncryptPKCS1RoundTripsCellphoneIdentifierPlaintext() throws { + func testEncryptRoundTripsCellphoneIdentifierPlaintext() throws { try assertRoundTrip("CELLPHONE*0609603632") } - func testEncryptPKCS1RoundTripsIDNumberIdentifierPlaintext() throws { + func testEncryptRoundTripsIDNumberIdentifierPlaintext() throws { try assertRoundTrip("IDNUMBER*8001015009087") } - func testEncryptPKCS1RoundTripsAccountNumberIdentifierPlaintext() throws { + func testEncryptRoundTripsAccountNumberIdentifierPlaintext() throws { try assertRoundTrip("ACCOUNTNUMBER*123456789") } - func testEncryptPKCS1ProducesNonDeterministicCiphertext() throws { + func testEncryptProducesNonDeterministicCiphertext() throws { let sut = Cryptography() - let first = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", - publicKey: publicKeyBase64) - let second = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", - publicKey: publicKeyBase64) + let first = try sut.encrypt(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) + let second = try sut.encrypt(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) XCTAssertNotEqual(first, second, - "PKCS#1 v1.5 padding is random ; two encryptions of the same plaintext must not be byte-equal") + "OAEP seeds each encryption randomly ; two encryptions of the same plaintext must not be byte-equal") } private func assertRoundTrip(_ plaintext: String, file: StaticString = #filePath, line: UInt = #line) throws { let sut = Cryptography() - let base64Ciphertext = try sut.encryptPKCS1(text: plaintext, - publicKey: publicKeyBase64) + let base64Ciphertext = try sut.encrypt(text: plaintext, + publicKey: publicKeyBase64) let ciphertext = try XCTUnwrap(Data(base64Encoded: base64Ciphertext), file: file, line: line) var decryptError: Unmanaged? guard let decryptedCF = SecKeyCreateDecryptedData(privateKey, - .rsaEncryptionPKCS1, + .rsaEncryptionOAEPSHA1, ciphertext as CFData, &decryptError) else { XCTFail("Decryption failed: \(decryptError.debugDescription)", diff --git a/Tests/PaystackSDKTests/Core/CryptographyTests.swift b/Tests/PaystackSDKTests/Core/CryptographyTests.swift index bd3c052..bbbbcc4 100644 --- a/Tests/PaystackSDKTests/Core/CryptographyTests.swift +++ b/Tests/PaystackSDKTests/Core/CryptographyTests.swift @@ -30,41 +30,6 @@ final class CryptographyTests: XCTestCase { XCTAssertEqual(decryptedString, clearText) } - func testPKCS1EncryptionRoundTripsToOriginalText() throws { - let clearText = "Hello World" - - let encryptedData = try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey) - let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, - privateKey: privateKey) - - XCTAssertEqual(decryptedString, clearText) - } - - func testPKCS1EncryptionWithSpecialCharactersRoundTrips() throws { - let mockCardConcatenation = "1234567890123456*123*01*23" - - let encryptedData = try serviceUnderTest.encryptPKCS1(text: mockCardConcatenation, - publicKey: publicKey) - let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, - privateKey: privateKey) - - XCTAssertEqual(decryptedString, mockCardConcatenation) - } - - func testPKCS1EncryptionOfTextOverLengthLimitThrowsError() { - let clearText = [String](repeating: "a", count: 200).joined(separator: "") - - XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey)) { error in - XCTAssertEqual(error as? CryptographyError, CryptographyError.encryptionFailed) - } - } - - func testPKCS1EncryptionWithNonBase64PublicKeyThrowsError() { - XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: "Hello World", publicKey: "ABC")) { error in - XCTAssertEqual(error as? CryptographyError, CryptographyError.invalidBase64String) - } - } - func testEncryptionWithSpecialCharacters() { let mockCardConcatenation = "1234567890123456*123*01*23" guard let encryptedData = try? serviceUnderTest.encrypt(text: mockCardConcatenation, @@ -221,22 +186,6 @@ extension Cryptography { return decryptedString } - func decryptPKCS1(base64String: String, privateKey: String) throws -> String { - guard let data = Data(base64Encoded: base64String) else { - throw CryptographyError.invalidBase64String - } - let key = try createKey(from: privateKey, isPublic: false) - - var error: Unmanaged? - guard let decrypted = SecKeyCreateDecryptedData(key, .rsaEncryptionPKCS1, - data as CFData, &error), - let decryptedString = String(data: decrypted as Data, encoding: .utf8) else { - throw CryptographyError.decryptionFailed - } - - return decryptedString - } - func decrypt(base64String: String, privateKey: String) throws -> T { let decryptedString = try decrypt(base64String: base64String, privateKey: privateKey) guard let encodedData = decryptedString.data(using: .utf8), From 0da5c0bc509dd41b207b729e47d09960563ec44c Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 7 Sep 2026 13:44:57 +0200 Subject: [PATCH 7/9] Move CI to macos-26 / Xcode 26.4 and swap Scan to Pay logo to SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump all four workflows from macos-15 to macos-26, Xcode 26.3 to 26.4, and the test destination from iOS 26.2 to 26.4 - Replace the scanToPayLogo PNG with an SVG - Add @zaheer-paystack to CODEOWNERS Two things to check before committing: - The new asset is named Scan to Pay Logo (2).svg — spaces and the (2) suffix suggest a browser download name. Worth renaming to something like scanToPayLogo.svg and updating Contents.json to match. - The SVG is registered only as 1x in the images array; if 2x/3x slots still reference the deleted PNG, the asset won't resolve at those scales. A vector asset usually wants "preserves-vector-representation": true and a single universal entry instead. --- .github/CODEOWNERS | 2 +- .github/workflows/build.yml | 10 +-- .github/workflows/deploy.yml | 6 +- .github/workflows/primary.yml | 8 +-- .github/workflows/release.yml | 2 +- .../scanToPayLogo.imageset/Contents.json | 2 +- .../Scan to Pay Logo (2).svg | 65 ++++++++++++++++++ .../scan-to-pay_logo-80-UG7U5qv_.png | Bin 4621 -> 0 bytes 8 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Scan to Pay Logo (2).svg delete mode 100644 Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c184081..c8dbf17 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # These owners will be the default owners for everything in the repo. # For more info, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @peter-paystack @michael-paystack @PaystackHQ/mobile @dami-paystack @vusi-paystack +* @peter-paystack @michael-paystack @zaheer-paystack @PaystackHQ/mobile @dami-paystack @vusi-paystack diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2c783a..b31ae4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ on: jobs: build: - runs-on: macos-15 + runs-on: macos-26 name: Build and Test Swift Package steps: @@ -16,7 +16,7 @@ jobs: - name: Select Xcode Version uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.3' + xcode-version: '26.4' - name: Setup environment run: | @@ -24,7 +24,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: @@ -33,7 +33,7 @@ jobs: PodLinting: - runs-on: macos-15 + runs-on: macos-26 name: Lint Podspec steps: @@ -52,7 +52,7 @@ jobs: release: if: ${{ github.event.pull_request.merged == true && github.head_ref == 'release/update-versions' }} - runs-on: macos-15 + runs-on: macos-26 needs: [build, PodLinting] steps: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0359b98..050028c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - runs-on: macos-15 + runs-on: macos-26 name: Deploy to Cocoapods Trunk steps: @@ -14,7 +14,7 @@ jobs: - name: Select Xcode Version uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.3' + xcode-version: '26.4' - name: Setup environment run: | @@ -22,7 +22,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO - name: setup-cocoapods uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0 diff --git a/.github/workflows/primary.yml b/.github/workflows/primary.yml index 62b7c85..27e7c29 100644 --- a/.github/workflows/primary.yml +++ b/.github/workflows/primary.yml @@ -21,7 +21,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SwiftPackage: - runs-on: macos-15 + runs-on: macos-26 name: Build and Test Swift Package steps: @@ -29,7 +29,7 @@ jobs: - name: Select Xcode Version uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.3' + xcode-version: '26.4' - name: Setup environment run: | @@ -37,7 +37,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: @@ -46,7 +46,7 @@ jobs: PodLinting: if: ${{ false }} - runs-on: macos-15 + runs-on: macos-26 name: Lint Podspec steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2850047..d75f7e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ on: jobs: release: - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json index 70d8fea..cd5a825 100644 --- a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json +++ b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "scan-to-pay_logo-80-UG7U5qv_.png", + "filename" : "Scan to Pay Logo (2).svg", "idiom" : "universal", "scale" : "1x" }, diff --git a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Scan to Pay Logo (2).svg b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Scan to Pay Logo (2).svg new file mode 100644 index 0000000..cb927f7 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Scan to Pay Logo (2).svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png b/Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png deleted file mode 100644 index d3b5953f513b6d951d92f5a9eaca4b0fffade107..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4621 zcmV+o67ubdP)d}3`)j)l4LR? zlbJ}=B!XaUV-_bvhQ&x?5DkbRNVm|)+6^?lFLYJCckle?y<2p3Q_yWboo`I)e0{!p z%YFBrd-ii~6Qv*pDM&#IQjmfaq#y+;NI?ox@RtLEJ|x&Qd2)dxV^gw29)-d#WrT>r zmPb~Y$l?Kxd%WM;svY$40_=kR*8IK?e1|?}VAF>LRwQzf2WbU0}rWKXc$T*v2_Y{u|+8-XQ2Cg}ul9|c01 zWX5kb*OvlofLnmI-RLR=USif3=ANzjcV=CFHU{`9U=FYn_zKXabA?g>{41ab+-|NX z0#5?h13S9$V*>E6feN^p0w-8x#_a-LX2zZZzH8PKhCjLH`ZZv*i+~$i#5BvI=L7@K z3d>^)6jrc8O!6~oJ%01p**58^TBrvlTPo>@Bdo`Xq*h9-FWtE3dvWzVeq#v)FP7oH z<&>AxOy>d<^>g4*>eT8pz(v6S1oi{|5IBL^h9iH1(**As@0_sl&D9@3#714|7ZtiVx8i6Fhf z5KtQQ#?x9iDMd|6izemqFSWw^^1A{@h#^|jFkqUeg{!qV;(DSG)WTTxF=2$jzu}M? zi1!jtYQ~M<+fH49H}r47VZb+lpQk{W(S!}J0M0VkZvuaBzAZG*ZUH_>fe!M9Ujnih z8bs&j7{VYkc)ej5Lox8iZeyD$5M*3X^}s{snw9T9AbYUgW-hke(ZID7c#lMgTbM(E z^|EzsGVjI$pE1{Lnae2cW-4H!0SUTJ9LJy&#_gK0Zz?jNL1MQ3QFK*@aQ;-Wo|3MCRFsr2!Q8+Nyg z1UK{kHDk4BQ94XfdC_Q%6>+7afOnH_hrdw>V&T%pCGGJzQ! zPeX=5_myt&{~(XzrQOt>$_I1y-IO9FfKRYIxYyt zNW%O0bM{Ob^wB_j0uA{I&sh;2a~(a;xBN|J!hIsmk%aI1Bs}Ne96Hw{uyWjQ-o*vY z@MPkkE-o5k8H5k{2dBnKR^F1C0AzN*HW{GzGfdhuEWf^!`$=U z@1Q^#*hSAtG8Y@gFU|GEW^T@~-$)#E)`H9D%#ovN_YG!|W8|1!)5cA;;{)IbA<>&y z0|3=3ta6|T8SKt2mr9S_JUYh)jh@nNJUnEHn~`lz#Z0RUUU+Y1V8{oO(C9N{qhS(T zFkAVR2~6T_`s)-V6&ITg#DOj855Rv1&ZfYc;7EM8YSBjrOdBiEe^cPDx0&a0i{qs0 zhZp#s6*->JN%PH03c|%I;61>Bz^BbT@&a`5cP#vLbI)17N*r`nz{3u|ldICK43;4* zaEa7nDU%GBOq>F<)AjtUNmo14>d)58W^76ElyH%tfeZ-;9h6P}avHAr7+n}R-g1fa zPZVUfY{4wlpO}sER6j7!I93fe8(>9X!EQ5SIQ#xK1#ybwBwG_FC>$#z)alOK&99fh zBz_i67?%hvG`6l*>gsm63}SDA($H5<76Mx~yUmBFYh9ZNJpWVX+u>$jE(O1C-t!!s zd_6;*rPQy?o~Bc0c!LlEt$>IoOUti!5l}gFl8?f#fmH;~ z__2_5c5CT9feuxXUnk?eC4z0ja`B`10!f;iwa=I8a5s117w&hcP_usUF^+PBJg`@T{j+pzh&a zi@^^9Olw^aV;YjI)g8Dr8cdy@;T$+C;80Y|LnG>2!nK5elq4j~7OZ{O8z_)x>Gp~l z6RwmL`zm7bJd{=FGY9@oC?02#CjzSv+tjQ-KXI^Fy}OP6yr8>9CuX}nDa(gV$Cu2S z+&-;HHpmw+hgM`OMVv%Iwnw!m-eHr~O2oI53tPS1Rg|t`F(DnxPsUItRY|QY{oU!H zIKpo+6lFBzoq6(P?+8uc)c+-ey3jzqSi64C$Uc@Mzlro?s4MiNd^*-Vf7DFe--CwF zyDwz-S+a1|j#!dUIYXXhuJ_T016&?GXK?HJ=#Oio*fM#-&mhz55o`Zt%C5=;iymJZS^&KVa_dc6AVf9U8Z$dU!4h5(k^it)(o`hC^>*y|&JgEjN?OSwfTcm?e1oP1J;Edv&EZKF%KyH9w>~ZP^2-lkiC72KEzZ7g+RZtkZ zMH|J_$=iMBOrbk_%x9m)zt^4bJI4yT5>Qr4w#V=Cg>_i*POf)1?L(J$zjb-TZyaDQsW=ROYm*!;VKGNFUu%OYBQyN@)QA=5ePWo(U?MW1I(&S=>xG zwN~hxOYJMC>&Y=7w#`#=e$)Jtf=D#HJZwkjXv?eC%BliOZtc^k9$JrFb!3y%-*MED z{4DP(6AbaEKx{9M?HR${FMu+G&u67BCvJ)5#7 zEp>7$hSi>?jF7aCU&B;Cta zuFeydIIKNA1d6l`p=}+M=4qHa@)$7=>*6%cbLz@Q-3k7J2cpiA;j)GZ9fpxi2iFhe zQtc6!%O)+CfZ~EoS3fYKwV@b1o{e?F4NioQ4j($NfYUPb2WtOZ0Nz)t1zSEj@p7A; zH-fUnsVNxrcYadki5iRGuF3CyI31+#+c$l}Y0jj5UoSji+4?eUfHUbs)B}{yA$hf? zLNNIxrhHpVy%;7nQb;?qttICl(leV^BPZwwW)vwQ-ozSHA&hN^brUiF5j0+|^~-Ld zM}WG2)gGu7^r}~{ZnN{a_pO`pG6N;s3Y+A&!K{@~fVa_sTM$#qw_xOv#n{cxoLKma z&35!H^x_AC$pi3v#1mpFeCaVNFIQ*AOw=8ceVscLM@oh0=pL%Cw?B=Z!LgDdVmKzM z?pMCIt#;l>K0V7sTd!VH6yC|nq?oRQa0ftf(56(c=f=_=YjPsLLcahdPt2c1YE9JCqz z(*d}S+xWbYyH>mimq~1-LH|n~lG;gEmd9y^gEDH7^v?`~aop_CUeML@JY7XSxaeuI z;n`%1zBqhdp1_P=~l z={>d(g`6z-b&Klk@3HV~4p&+~ogZy)XiH09gw6F05)01Il&=hIwtii#sO5}k`wjH~ zD$|BS>L1yTdNckkZ_R-%jq^(G#^ogz>AU~Oo%F(0_RdzQ*i|6rF;F+dBC#TS-Tt2; zLtPKAaW4eq!csP&(UK20w{z6`1CiRfW9BlN_e?o;soHHcrI~9WPUEgYk2^B{>p#?w=m|) zdnb;LLYL?|B&c^Ws|Nk(K<(+WlCL;g-VBd~2E14nvK&n@uN>LxH;~XQPor1}Euq(i zrz$)tGSKd=ky>rgWIckpvykP~p(O1MsiPvSqUAz15RQ+kK=XcI7-GcJ%%i& zDtC3|A^MmqbP7_Cf)u161t~~D3Q~}Q6r>;pe@^hfw>V8S@9Ka=00000NkvXXu0mjf D##`Uq From 02f202a6e4c6b03916faa7c358e7ca60b6a103b5 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Mon, 7 Sep 2026 16:30:30 +0200 Subject: [PATCH 8/9] Unpin simulator runtime in CI test destination The macos-26 runner has Xcode 26.4 but not the iOS 26.4 simulator runtime, so "OS=26.4,name=iPhone 17 Pro" failed to match a device. Drop the OS and -sdk pins so xcodebuild uses whatever runtime the runner has. --- .github/workflows/build.yml | 2 +- .github/workflows/deploy.yml | 2 +- .github/workflows/primary.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b31ae4f..c164f0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 050028c..f316fce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO - name: setup-cocoapods uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0 diff --git a/.github/workflows/primary.yml b/.github/workflows/primary.yml index 27e7c29..036d0f5 100644 --- a/.github/workflows/primary.yml +++ b/.github/workflows/primary.yml @@ -37,7 +37,7 @@ jobs: - name: Build and Run tests run: | - xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.4 -destination "OS=26.4,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO + xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO brew install sonar-scanner bundle exec fastlane sonar_scan env: From 36fcf57c586b7aaaff51e27e7782e70165d1f1c0 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 8 Sep 2026 14:22:13 +0200 Subject: [PATCH 9/9] Decode the QR Pusher envelope and fall back to checkPending The Scan to Pay / SnapScan QR channel publishes its own shape rather than the flat Charge3DSResponse used by card 3DS, mobile money, Zap and bank transfer: top-level `status` is a Bool, `trans` is a JSON number, and the redirect key is spelled `redirecturl` (all lowercase, so convertFromSnakeCase never maps it). Add QRPusherResponse with explicit coding keys and a flexible String/Int decode for `trans`, and return it from listenForQRResponse. When the single-shot listener errors out, QRViewModel now degrades to one checkPending call instead of silently logging: success routes to the container, failure surfaces the server message, non-terminal leaves the QR on screen with the manual button available. The check is armed once per QR so a failed re-subscribe from the manual tap can't loop, and retry() re-arms it. Fixtures updated to the real wire payloads. --- Package.swift | 3 +- Sources/PaystackSDK/API/Charge/QR.swift | 16 ++- .../Models/Charge/QRPusherResponse.swift | 72 ++++++++++ .../Models/ChargeCardTransaction.swift | 5 + .../Charge/QR/Viewmodels/QRViewModel.swift | 48 ++++++- .../PaystackSDKTests/API/Charge/QRTests.swift | 82 ++++++++++- .../API/Charge/Resources/QRPusherFailed.json | 15 +- .../API/Charge/Resources/QRPusherSuccess.json | 16 ++- .../Resources/QRPusherTransAsString.json | 8 ++ .../UI/Charge/QR/QRViewModelTests.swift | 131 ++++++++++++++++++ .../QRRepositoryImplementationTests.swift | 28 ++++ 11 files changed, 405 insertions(+), 19 deletions(-) create mode 100644 Sources/PaystackSDK/Core/Models/Models/Charge/QRPusherResponse.swift create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/QRPusherTransAsString.json diff --git a/Package.swift b/Package.swift index d213429..5d42296 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,8 @@ let package = Package( .copy("API/Charge/Resources/CapitecPayPusherPending.json"), .copy("API/Charge/Resources/CapitecRequeryResponse.json"), .copy("API/Charge/Resources/QRPusherSuccess.json"), - .copy("API/Charge/Resources/QRPusherFailed.json") + .copy("API/Charge/Resources/QRPusherFailed.json"), + .copy("API/Charge/Resources/QRPusherTransAsString.json") ]) ] diff --git a/Sources/PaystackSDK/API/Charge/QR.swift b/Sources/PaystackSDK/API/Charge/QR.swift index 97a4003..fb693f7 100644 --- a/Sources/PaystackSDK/API/Charge/QR.swift +++ b/Sources/PaystackSDK/API/Charge/QR.swift @@ -29,10 +29,14 @@ public extension Paystack { } /// 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. + /// returned by ``generateQR(_:)`` — the same channel serves Scan to Pay + /// and SnapScan. + /// + /// The QR channel publishes its own envelope rather than the flat + /// ``Charge3DSResponse`` shape used by card 3-D Secure, mobile money, + /// Zap and bank transfer: the top-level `status` is a `Bool`, `trans` + /// is a JSON number, and the redirect key is spelled `redirecturl`. + /// See ``QRPusherResponse``. /// /// The underlying listener is single-shot per the existing /// `PusherSubscriptionListener` contract — one event resolves the @@ -41,10 +45,10 @@ public extension Paystack { /// - Parameter channelName: The `data.channel` value returned by /// ``generateQR(_:)`` (for example /// `"api_mpass_olti_qr_51826223921246"`). - /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// - Returns: A ``Service`` carrying a ``QRPusherResponse`` on the /// first event the channel emits. func listenForQRResponse(onChannel channelName: String) - -> Service { + -> Service { let subscription: any Subscription = PusherSubscription( channelName: channelName, eventName: "response") return Service(subscription) diff --git a/Sources/PaystackSDK/Core/Models/Models/Charge/QRPusherResponse.swift b/Sources/PaystackSDK/Core/Models/Models/Charge/QRPusherResponse.swift new file mode 100644 index 0000000..3d2229b --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/Charge/QRPusherResponse.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct QRPusherResponse: Decodable, Equatable { + public var status: Bool + public var message: String? + public var response: String? + public var reference: String? + public var transactionReference: String? + public var transaction: String? + public var redirectUrl: String? + public var page: QRPusherPage? + + enum CodingKeys: String, CodingKey { + case status, message, response, reference, page + case transaction = "trans" + case transactionReference = "trxref" + case redirectUrl = "redirecturl" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + status = try container.decode(Bool.self, forKey: .status) + message = try container.decodeIfPresent(String.self, forKey: .message) + response = try container.decodeIfPresent(String.self, forKey: .response) + reference = try container.decodeIfPresent(String.self, forKey: .reference) + transactionReference = try container.decodeIfPresent( + String.self, forKey: .transactionReference) + transaction = Self.decodeFlexibleString(from: container, forKey: .transaction) + redirectUrl = try container.decodeIfPresent(String.self, forKey: .redirectUrl) + page = try container.decodeIfPresent(QRPusherPage.self, forKey: .page) + } + + public init(status: Bool, + message: String? = nil, + response: String? = nil, + reference: String? = nil, + transactionReference: String? = nil, + transaction: String? = nil, + redirectUrl: String? = nil, + page: QRPusherPage? = nil) { + self.status = status + self.message = message + self.response = response + self.reference = reference + self.transactionReference = transactionReference + self.transaction = transaction + self.redirectUrl = redirectUrl + self.page = page + } + + private static func decodeFlexibleString( + from container: KeyedDecodingContainer, + forKey key: CodingKeys) -> String? { + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return String(value) + } + return nil + } +} + +public struct QRPusherPage: Decodable, Equatable { + public var redirectUrl: String? + public var successMessage: String? + + public init(redirectUrl: String? = nil, successMessage: String? = nil) { + self.redirectUrl = redirectUrl + self.successMessage = successMessage + } +} diff --git a/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCardTransaction.swift b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCardTransaction.swift index d77a9b3..a22aa5c 100644 --- a/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCardTransaction.swift +++ b/Sources/PaystackUI/Charge/ChargeCard/Models/ChargeCardTransaction.swift @@ -25,6 +25,11 @@ extension ChargeCardTransaction { return ChargeCardTransaction(status: status) } + static func from(_ response: QRPusherResponse) -> Self { + ChargeCardTransaction(status: response.status ? .success : .failed, + message: response.message) + } + } // MARK: - Previews diff --git a/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift b/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift index 62605a1..eee5bfd 100644 --- a/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift +++ b/Sources/PaystackUI/Charge/QR/Viewmodels/QRViewModel.swift @@ -20,6 +20,9 @@ class QRViewModel: ObservableObject { private var pusherTask: Task? private var checkPendingTask: Task? + private var fallbackCheckTask: Task? + + private var hasRunPusherFallbackCheck = false init(chargeContainer: ChargeContainer, transactionDetails: VerifyAccessCode, @@ -34,6 +37,7 @@ class QRViewModel: ObservableObject { deinit { pusherTask?.cancel() checkPendingTask?.cancel() + fallbackCheckTask?.cancel() } var variant: QRVariant { config.variant } @@ -48,6 +52,7 @@ class QRViewModel: ObservableObject { func retry() async { cancelAllTasks() inlineBanner = nil + hasRunPusherFallbackCheck = false state = .loadingQR await generate() } @@ -128,8 +133,47 @@ class QRViewModel: ObservableObject { let update = try await repository.listenForResponse(onChannel: channel) await processTransactionUpdate(update) } catch { - Logger.error("QR Pusher await failed: %@", + Logger.error("QR Pusher await failed, falling back to checkPending: %@", arguments: error.localizedDescription) + await runPusherFallbackCheck() + } + } + + @MainActor + private func runPusherFallbackCheck() async { + guard case .awaitingScan(let details) = state else { return } + guard !hasRunPusherFallbackCheck else { return } + hasRunPusherFallbackCheck = true + + fallbackCheckTask?.cancel() + fallbackCheckTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await self.repository.checkPending( + accessCode: self.transactionDetails.accessCode) + await self.reactToPusherFallbackResult(result, details: details) + } catch { + Logger.error("QR Pusher fallback checkPending failed: %@", + arguments: error.localizedDescription) + } + } + } + + @MainActor + private func reactToPusherFallbackResult(_ result: ChargeCardTransaction, + details: QRDetails) { + guard case .awaitingScan = state else { return } + 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: + Logger.info("QR Pusher fallback: non-terminal status %@", + arguments: String(describing: result.status)) } } @@ -158,5 +202,7 @@ class QRViewModel: ObservableObject { cancelPusherTask() checkPendingTask?.cancel() checkPendingTask = nil + fallbackCheckTask?.cancel() + fallbackCheckTask = nil } } diff --git a/Tests/PaystackSDKTests/API/Charge/QRTests.swift b/Tests/PaystackSDKTests/API/Charge/QRTests.swift index f3ecb49..bf27b7a 100644 --- a/Tests/PaystackSDKTests/API/Charge/QRTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/QRTests.swift @@ -55,6 +55,8 @@ final class QRTests: PSTestCase { XCTAssertEqual(request.source, "checkout") } + // MARK: - Pusher envelope (Scan to Pay / SnapScan) + func testListenForQRResponseSubscribesToProvidedChannel() async throws { let channelName = "api_mpass_olti_qr_51826223921246" mockSubscriptionListener @@ -64,7 +66,66 @@ final class QRTests: PSTestCase { let result = try await serviceUnderTest .listenForQRResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, .success) + XCTAssertEqual(result.status, true) + XCTAssertEqual(result.message, "Payment Successful") + XCTAssertEqual(result.response, "Approved") + XCTAssertEqual(result.reference, "T195096317254637") + XCTAssertEqual(result.transactionReference, "T195096317254637") + } + + /// `trans` arrives as a JSON number on this channel — the field that + /// broke the previous `Charge3DSResponse` decode. + func testListenForQRResponseDecodesNumericTransAsString() 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.transaction, "6537606334") + } + + func testListenForQRResponseDecodesStringTransToo() async throws { + let channelName = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "QRPusherTransAsString") + + let result = try await serviceUnderTest + .listenForQRResponse(onChannel: channelName).async() + + XCTAssertEqual(result.transaction, "6537606334") + XCTAssertEqual(result.status, true) + } + + /// `redirecturl` is all lowercase on the wire, so `.convertFromSnakeCase` + /// never maps it — it needs the explicit coding key. + func testListenForQRResponseDecodesLowercaseRedirecturlKey() 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.redirectUrl, + "https://rian.co.za/ptest/callback.php?trxref=T195096317254637&reference=T195096317254637") + } + + func testListenForQRResponseDecodesPageWithNullMembers() 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.page, QRPusherPage(redirectUrl: nil, successMessage: nil)) } func testListenForQRResponseDecodesFailedShape() async throws { @@ -76,7 +137,24 @@ final class QRTests: PSTestCase { let result = try await serviceUnderTest .listenForQRResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.status, false) XCTAssertEqual(result.message, "Wallet declined the payment") + XCTAssertEqual(result.response, "Declined") + } + + func testListenForQRResponseDecodesMinimalEnvelope() async throws { + let channelName = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString("{ \"status\": true }") + + let result = try await serviceUnderTest + .listenForQRResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, true) + XCTAssertNil(result.message) + XCTAssertNil(result.transaction) + XCTAssertNil(result.redirectUrl) + XCTAssertNil(result.page) } } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json index 842c2e5..7e999c8 100644 --- a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json @@ -1,6 +1,13 @@ { - "status": "failed", - "trans": "5900549926", - "trxref": "T_qr_5900549926", - "message": "Wallet declined the payment" + "redirecturl": "https://rian.co.za/ptest/callback.php?trxref=T195096317254637&reference=T195096317254637", + "trans": 6537606334, + "trxref": "T195096317254637", + "reference": "T195096317254637", + "status": false, + "message": "Wallet declined the payment", + "response": "Declined", + "page": { + "redirect_url": null, + "success_message": null + } } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json index 6eda0ea..bea8f1b 100644 --- a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json @@ -1,7 +1,13 @@ { - "status": "success", - "trans": "5900549926", - "trxref": "T_qr_5900549926", - "reference": "T_qr_5900549926", - "message": "Payment received" + "redirecturl": "https://rian.co.za/ptest/callback.php?trxref=T195096317254637&reference=T195096317254637", + "trans": 6537606334, + "trxref": "T195096317254637", + "reference": "T195096317254637", + "status": true, + "message": "Payment Successful", + "response": "Approved", + "page": { + "redirect_url": null, + "success_message": null + } } diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherTransAsString.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherTransAsString.json new file mode 100644 index 0000000..1356f9b --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherTransAsString.json @@ -0,0 +1,8 @@ +{ + "trans": "6537606334", + "trxref": "T195096317254637", + "reference": "T195096317254637", + "status": true, + "message": "Payment Successful", + "response": "Approved" +} diff --git a/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift index 93c2b60..b018d30 100644 --- a/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/QR/QRViewModelTests.swift @@ -75,6 +75,11 @@ final class QRViewModelTests: XCTestCase { func testUserTappedICompletedPaymentTransitionsToVerifying() async { mockRepository.expectedDetails = .scanToPayExample + // Non-terminal Pusher event: resolves the single-shot listener without + // erroring, so the failure fallback stays out of this test's way. + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .pending) + ] mockRepository.expectedCheckPendingResults = [ ChargeCardTransaction(status: .pending) ] @@ -96,6 +101,11 @@ final class QRViewModelTests: XCTestCase { func testUserTappedICompletedPaymentOnSuccessRoutesToContainer() async { mockRepository.expectedDetails = .scanToPayExample + // Non-terminal Pusher event: resolves the single-shot listener without + // erroring, so the failure fallback stays out of this test's way. + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .pending) + ] mockRepository.expectedCheckPendingResults = [ ChargeCardTransaction(status: .success) ] @@ -113,6 +123,11 @@ final class QRViewModelTests: XCTestCase { func testUserTappedICompletedPaymentOnFailedTransitionsToError() async { mockRepository.expectedDetails = .scanToPayExample + // Non-terminal Pusher event: resolves the single-shot listener without + // erroring, so the failure fallback stays out of this test's way. + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .pending) + ] mockRepository.expectedCheckPendingResults = [ ChargeCardTransaction(status: .failed, message: "Bank declined") ] @@ -129,6 +144,11 @@ final class QRViewModelTests: XCTestCase { func testUserTappedICompletedPaymentOnPendingReturnsToAwaitingScanWithBanner() async { mockRepository.expectedDetails = .scanToPayExample + // Non-terminal Pusher event: resolves the single-shot listener without + // erroring, so the failure fallback stays out of this test's way. + mockRepository.expectedListenResponses = [ + ChargeCardTransaction(status: .pending) + ] mockRepository.expectedCheckPendingResults = [ ChargeCardTransaction(status: .pending) ] @@ -222,6 +242,117 @@ final class QRViewModelTests: XCTestCase { .error(ChargeError(message: "Bank declined"))) } + // MARK: - Pusher failure degrades to one checkPending + + func testListenFailureRunsCheckPendingOnce() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending) + ] + + await serviceUnderTest.onAppear() + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(mockRepository.checkPendingCallCount, 1) + XCTAssertEqual(mockRepository.lastCheckPendingAccessCode, + serviceUnderTest.transactionDetails.accessCode) + } + + /// A non-terminal answer is the expected case while the QR is unscanned: + /// stay put, stay silent, leave the manual button available. + func testListenFailureWithPendingLeavesQROnScreenWithNoBanner() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending) + ] + + await serviceUnderTest.onAppear() + try await Task.sleep(nanoseconds: 300_000_000) + + if case .awaitingScan = serviceUnderTest.state { + // ok + } else { + XCTFail("Expected .awaitingScan, got \(serviceUnderTest.state)") + } + XCTAssertNil(serviceUnderTest.inlineBanner) + } + + func testListenFailureWithSuccessRoutesToContainer() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + 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 testListenFailureWithFailedTransitionsToErrorWithServerMessage() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .failed, message: "Wallet declined the payment") + ] + + await serviceUnderTest.onAppear() + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Wallet declined the payment"))) + } + + /// The manual button re-subscribes on a non-terminal answer, which can + /// fail again — the fallback must not fire a second time and loop. + func testFallbackCheckRunsAtMostOncePerQR() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending), + ChargeCardTransaction(status: .pending), + ChargeCardTransaction(status: .pending) + ] + + await serviceUnderTest.onAppear() + try await Task.sleep(nanoseconds: 300_000_000) + let afterFallback = mockRepository.checkPendingCallCount + + // Manual tap: returns to .awaitingScan with a banner and re-subscribes, + // and that fresh subscription fails too. + mockRepository.expectedListenError = MockError.stubNotProvided + await MainActor.run { serviceUnderTest.userTappedICompletedPayment() } + try await Task.sleep(nanoseconds: 400_000_000) + + XCTAssertEqual(afterFallback, 1) + // One from the fallback, one from the manual tap — not a third. + XCTAssertEqual(mockRepository.checkPendingCallCount, 2) + } + + func testRetryReArmsTheFallbackCheck() async throws { + mockRepository.expectedDetails = .scanToPayExample + mockRepository.expectedListenError = MockError.stubNotProvided + mockRepository.expectedCheckPendingResults = [ + ChargeCardTransaction(status: .pending), + ChargeCardTransaction(status: .pending) + ] + + await serviceUnderTest.onAppear() + try await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(mockRepository.checkPendingCallCount, 1) + + mockRepository.expectedListenError = MockError.stubNotProvided + await serviceUnderTest.retry() + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(mockRepository.checkPendingCallCount, 2) + } + // MARK: - Retry (PR QR-D) func testRetryResetsToLoadingQRAndCallsGenerateAgain() async { diff --git a/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift index 20b99fb..ba04d9d 100644 --- a/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/QRRepository/QRRepositoryImplementationTests.swift @@ -68,6 +68,34 @@ final class QRRepositoryImplementationTests: PSTestCase { .listenForResponse(onChannel: channel) XCTAssertEqual(result.status, .success) + XCTAssertEqual(result.message, "Payment Successful") + } + + func testListenForResponseMapsFalseStatusToFailedWithServerMessage() async throws { + let channel = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "QRPusherFailed") + + let result = try await serviceUnderTest + .listenForResponse(onChannel: channel) + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "Wallet declined the payment") + } + + func testListenForResponseThrowsOnUndecodablePayload() async { + let channel = "api_mpass_olti_qr_51826223921246" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString("not json at all") + + do { + _ = try await serviceUnderTest.listenForResponse(onChannel: channel) + XCTFail("Expected a decoding error") + } catch { + // expected — the view model degrades to a single checkPending + } } func testCheckPendingHitsSharedSDKEndpointNotAQRSpecificOne() async throws {