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