diff --git a/Package.swift b/Package.swift index 7da9c31..30f92d5 100644 --- a/Package.swift +++ b/Package.swift @@ -43,7 +43,13 @@ let package = Package( .copy("API/Transactions/Resources/VerifyAccessCode.json"), .copy("API/Charge/Resources/ChargeAuthenticationResponse.json"), .copy("API/Other/Resources/AddressStatesResponse.json"), - .copy("API/Charge/Resources/ChargeMobileMoneyResponse.json") + .copy("API/Charge/Resources/ChargeMobileMoneyResponse.json"), + .copy("API/Charge/Resources/PayWithTransferResponse.json"), + .copy("API/Charge/Resources/PayWithTransferPusherSuccess.json"), + .copy("API/Charge/Resources/PayWithTransferPusherCreditReceived.json"), + .copy("API/Charge/Resources/PayWithTransferPusherCreditPending.json"), + .copy("API/Charge/Resources/PayWithTransferPusherCreditRejected.json"), + .copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json") ]) ] diff --git a/Sources/PaystackSDK/API/Charge/PayWithTransfer.swift b/Sources/PaystackSDK/API/Charge/PayWithTransfer.swift new file mode 100644 index 0000000..07d4df0 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/PayWithTransfer.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Public Pay-with-Transfer surface. Used by the UI module to provision a +/// virtual bank account and listen for Pusher status updates ; can also be +/// called directly by integrators driving their own UI on top of +/// `PaystackCore`. +public extension Paystack { + + private var service: PayWithTransferService { + return PayWithTransferServiceImplementation(config: config) + } + + /// Provisions a one-time virtual bank account for the customer to make + /// a transfer to. The response's `pusherChannel` should be subscribed + /// to via ``listenForTransferResponse(onChannel:)`` for real-time + /// status updates ; the response's `accountExpiresAt` drives the + /// customer-facing countdown. + /// + /// - Parameter request: Required configuration for the virtual account. + /// `preferredProvider` is optional — when omitted, the backend picks + /// a default (typically `paystack-titan`). + /// - Returns: A ``Service`` with the ``PayWithTransferResponse`` payload. + func payWithTransfer(_ request: PayWithTransferRequest) + -> Service { + return service.postPayWithTransfer(request) + } + + /// Listens for Pay-with-Transfer status updates on the Pusher channel + /// returned by ``payWithTransfer(_:)``. The underlying listener is + /// single-shot per the existing `PusherSubscriptionListener` contract, + /// so callers that need to keep listening through transient statuses + /// must re-subscribe after each event. + /// + /// - Parameter channelName: The `pusherChannel` value returned from + /// `payWithTransfer` (e.g. `PWT6215047322`). + /// - Returns: A ``Service`` carrying a ``PayWithTransferPusherResponse`` + /// on the first event the channel emits. + func listenForTransferResponse(onChannel channelName: String) + -> Service { + let subscription: any Subscription = PusherSubscription( + channelName: channelName, eventName: "response") + return Service(subscription) + } +} diff --git a/Sources/PaystackSDK/API/Charge/PayWithTransferService.swift b/Sources/PaystackSDK/API/Charge/PayWithTransferService.swift new file mode 100644 index 0000000..f97a419 --- /dev/null +++ b/Sources/PaystackSDK/API/Charge/PayWithTransferService.swift @@ -0,0 +1,21 @@ +import Foundation + +protocol PayWithTransferService: PaystackService { + func postPayWithTransfer(_ request: PayWithTransferRequest) + -> Service +} + +struct PayWithTransferServiceImplementation: PayWithTransferService { + + var config: PaystackConfig + + var parentPath: String { + return "checkout" + } + + func postPayWithTransfer(_ request: PayWithTransferRequest) + -> Service { + return post("/pay_with_transfer", request) + .asService() + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/PayWithTransferPusherResponse.swift b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferPusherResponse.swift new file mode 100644 index 0000000..7857998 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferPusherResponse.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct PayWithTransferPusherResponse: Codable, Equatable { + public let status: String + public let message: String + public let data: PayWithTransferEventData? + public let errors: [String]? +} + +public struct PayWithTransferEventData: Codable, Equatable { + public let messageType: String? + public let transactionId: String? + public let referenceId: String? + public let reference: String? + public let trxref: String? + public let trans: String? + public let response: String? + public let redirecturl: String? +} diff --git a/Sources/PaystackSDK/Core/Models/Models/PayWithTransferRequest.swift b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferRequest.swift new file mode 100644 index 0000000..9c49e14 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PayWithTransferRequest: Codable, Equatable { + public let fulfilLateNotification: Bool + public let transactionId: Int + public let preferredProvider: String? + + public init(fulfilLateNotification: Bool, + transactionId: Int, + preferredProvider: String? = nil) { + self.fulfilLateNotification = fulfilLateNotification + self.transactionId = transactionId + self.preferredProvider = preferredProvider + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/PayWithTransferResponse.swift b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferResponse.swift new file mode 100644 index 0000000..4ed0809 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/PayWithTransferResponse.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct PayWithTransferResponse: Codable, Equatable { + public let status: Bool + public let message: String + public let data: PayWithTransferData +} + +public struct PayWithTransferData: Codable, Equatable { + public let accountName: String + public let accountNumber: String + public let transactionReference: String + public let bank: TransferBank + public let accountExpiresAt: Date + public let assignmentExpiresAt: Date + public let transactionId: String + public let pusherChannel: String +} + +public struct TransferBank: Codable, Equatable { + public let slug: String + public let name: String + public let id: Int +} diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift index 01f7735..514e345 100644 --- a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/ChannelOptions.swift @@ -17,7 +17,7 @@ public struct ChannelOptions: Codable { enum CodingKeys: String, CodingKey { case ussd case qrCode = "qr" - case bankTransfer = "bank_transfer" + case bankTransfer case mobileMoney } } diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/MerchantChannelSettings.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/MerchantChannelSettings.swift new file mode 100644 index 0000000..49eda9d --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/MerchantChannelSettings.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct MerchantChannelSettings: Codable, Equatable { + public var bankTransfer: BankTransferMerchantSettings? + + public init(bankTransfer: BankTransferMerchantSettings? = nil) { + self.bankTransfer = bankTransfer + } +} + +public struct BankTransferMerchantSettings: Codable, Equatable { + public var fulfilLateNotification: Bool? + + public init(fulfilLateNotification: Bool? = nil) { + self.fulfilLateNotification = fulfilLateNotification + } +} diff --git a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift index 3915ff8..24b1129 100644 --- a/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift +++ b/Sources/PaystackSDK/Core/Models/Models/VerifyAccessCode/VerifyAccessCodeData.swift @@ -12,11 +12,13 @@ public struct VerifyAccessCodeData: Decodable { public var currency: String public var channels: [Channel] public var channelOptions: ChannelOptions + public var merchantChannelSettings: MerchantChannelSettings? public var publicEncryptionKey: String public init(id: Int?, email: String, amount: Decimal, reference: String, accessCode: String, merchantLogo: String? = nil, merchantName: String, domain: Domain, currency: String, channels: [Channel], channelOptions: ChannelOptions, + merchantChannelSettings: MerchantChannelSettings? = nil, publicEncryptionKey: String) { self.id = id self.email = email @@ -29,6 +31,7 @@ public struct VerifyAccessCodeData: Decodable { self.currency = currency self.channels = channels self.channelOptions = channelOptions + self.merchantChannelSettings = merchantChannelSettings self.publicEncryptionKey = publicEncryptionKey } } diff --git a/Sources/PaystackSDK/Core/Utils/DateFormatter.swift b/Sources/PaystackSDK/Core/Utils/DateFormatter.swift index b2d4098..9c2a46b 100644 --- a/Sources/PaystackSDK/Core/Utils/DateFormatter.swift +++ b/Sources/PaystackSDK/Core/Utils/DateFormatter.swift @@ -4,7 +4,8 @@ public extension DateFormatter { static var paystackFormatter: DateFormatter { let formatter = DateFormatter() - formatter.locale = .current + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") formatter.dateFormat = DateFormat.paystack.rawValue return formatter } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift new file mode 100644 index 0000000..598f8e2 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift @@ -0,0 +1,9 @@ +import Foundation + +struct BankTransferConfig: Equatable { + let fulfilLateNotification: Bool + + let transactionId: Int + + let availableProviders: [String] +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferDetails.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferDetails.swift new file mode 100644 index 0000000..97e6f81 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferDetails.swift @@ -0,0 +1,42 @@ +import Foundation +import PaystackCore + +struct BankTransferDetails: Equatable { + let accountName: String + let accountNumber: String + let bankName: String + let bankSlug: String + let transactionReference: String + let pusherChannel: String + let accountExpiresAt: Date + let transactionId: String +} + +extension BankTransferDetails { + + static func from(_ response: PayWithTransferResponse) -> Self { + BankTransferDetails( + accountName: response.data.accountName, + accountNumber: response.data.accountNumber, + bankName: response.data.bank.name, + bankSlug: response.data.bank.slug, + transactionReference: response.data.transactionReference, + pusherChannel: response.data.pusherChannel, + accountExpiresAt: response.data.accountExpiresAt, + transactionId: response.data.transactionId) + } +} + +extension BankTransferDetails { + static var example: BankTransferDetails { + BankTransferDetails( + accountName: "PAYSTACK CHECKOUT", + accountNumber: "9985488398", + bankName: "Paystack-Titan", + bankSlug: "titan-paystack", + transactionReference: "T6215047322I100043S0g703", + pusherChannel: "PWT6215047322", + accountExpiresAt: Date().addingTimeInterval(30 * 60), + transactionId: "6215047322") + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProviderCatalog.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProviderCatalog.swift new file mode 100644 index 0000000..00be579 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProviderCatalog.swift @@ -0,0 +1,28 @@ +import Foundation + +enum BankTransferProviderCatalog { + + static func displayName(forSlug slug: String) -> String { + switch slug.lowercased() { + case "wema-bank": + return "Wema Bank" + case "titan-paystack": + return "Paystack-Titan" + case "paystack-mfb": + return "Paystack MFB" + default: + return titleCased(slug) + } + } + + private static func titleCased(_ slug: String) -> String { + slug.split(separator: "-") + .map { segment -> String in + let lowered = segment.lowercased() + guard let first = lowered.first else { return "" } + return first.uppercased() + lowered.dropFirst() + } + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferState.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferState.swift new file mode 100644 index 0000000..c7334c7 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferState.swift @@ -0,0 +1,20 @@ +import Foundation + +enum BankTransferState: Equatable { + + case loading(message: String? = nil) + + case awaitingPayment(BankTransferDetails) + + case confirmingPayment(BankTransferDetails, phase: ConfirmingPhase) + + case takingLongerThanExpected(BankTransferDetails) + + case delayedConfirmation(BankTransferDetails) + + case refundInitiated(BankTransferDetails, message: String) + + case error(ChargeError) + + case fatalError(error: ChargeError) +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferStatus.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferStatus.swift new file mode 100644 index 0000000..bf6ce5a --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferStatus.swift @@ -0,0 +1,54 @@ +import Foundation + +enum BankTransferStatus: Equatable { + case success + + case creditRequestPending + + case creditRequestReceived + + case creditRequestRejected + + case incorrectAmountSent + + case pending + + case requery + + case failed + + case unknown(String) + + init(rawStatus: String, message: String?) { + switch rawStatus { + case "success": + self = .success + case "transfer-credit-request-pending": + self = .creditRequestPending + case "transfer-credit-request-received": + self = .creditRequestReceived + case "transfer-credit-request-rejected": + self = .creditRequestRejected + case "incorrect-amount-sent": + self = .incorrectAmountSent + case "pending": + self = .pending + case "requery": + self = .requery + case "failed": + self = .failed + default: + self = .unknown(rawStatus) + } + } + + var isTerminal: Bool { + switch self { + case .success, .creditRequestRejected, .incorrectAmountSent, .failed: + return true + case .creditRequestPending, .creditRequestReceived, + .pending, .requery, .unknown: + return false + } + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferTransactionUpdate.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferTransactionUpdate.swift new file mode 100644 index 0000000..71b52b6 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferTransactionUpdate.swift @@ -0,0 +1,21 @@ +import Foundation +import PaystackCore + +struct BankTransferTransactionUpdate: Equatable { + let status: BankTransferStatus + let message: String? + let reference: String? + let transactionId: String? +} + +extension BankTransferTransactionUpdate { + + static func from(_ response: PayWithTransferPusherResponse) -> Self { + BankTransferTransactionUpdate( + status: BankTransferStatus(rawStatus: response.status, + message: response.message), + message: response.message, + reference: response.data?.reference ?? response.data?.trxref, + transactionId: response.data?.transactionId ?? response.data?.referenceId) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/ConfirmingPhase.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/ConfirmingPhase.swift new file mode 100644 index 0000000..6932821 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/ConfirmingPhase.swift @@ -0,0 +1,8 @@ +import Foundation + +enum ConfirmingPhase: Equatable { + + case waitingForCredit + + case transferOnTheWay +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Repository/BankTransferRepository.swift b/Sources/PaystackUI/Charge/BankTransfer/Repository/BankTransferRepository.swift new file mode 100644 index 0000000..a560fc7 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Repository/BankTransferRepository.swift @@ -0,0 +1,45 @@ +import Foundation +import PaystackCore + +protocol BankTransferRepository { + func payWithTransfer(fulfilLateNotification: Bool, + transactionId: Int, + preferredProvider: String?) async throws -> BankTransferDetails + + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction + + func listenForTransferResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate +} + +struct BankTransferRepositoryImplementation: BankTransferRepository { + + let paystack: Paystack + + init() { + self.paystack = PaystackContainer.instance.retrieve() + } + + func payWithTransfer(fulfilLateNotification: Bool, + transactionId: Int, + preferredProvider: String?) async throws -> BankTransferDetails { + let request = PayWithTransferRequest( + fulfilLateNotification: fulfilLateNotification, + transactionId: transactionId, + preferredProvider: preferredProvider) + let response = try await paystack.payWithTransfer(request).async() + return BankTransferDetails.from(response) + } + + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction { + let response = try await paystack.checkPendingCharge(forAccessCode: accessCode).async() + return ChargeCardTransaction.from(response) + } + + func listenForTransferResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate { + let response = try await paystack + .listenForTransferResponse(onChannel: channelName).async() + return BankTransferTransactionUpdate.from(response) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift new file mode 100644 index 0000000..a3d57a8 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift @@ -0,0 +1,342 @@ +import Foundation +import PaystackCore + +class BankTransferViewModel: ObservableObject { + + + var confirmationWindowSeconds: Int = 10 * 60 + + static var refundInitiatedFallbackMessage = + "You sent an incorrect amount. We've started a refund — funds will be returned to the account you sent from within a few business days." + + static var failedFallbackMessage = "Something went wrong" + + let chargeContainer: ChargeContainer + let repository: BankTransferRepository + let transactionDetails: VerifyAccessCode + let config: BankTransferConfig + + @Published + var state: BankTransferState = .loading() + + @Published + var confirmationElapsedSeconds: Int = 0 + + private var confirmationTimerTask: Task? + private var pusherTask: Task? + private var requeryTask: Task? + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: BankTransferConfig, + repository: BankTransferRepository = BankTransferRepositoryImplementation()) { + self.chargeContainer = chargeContainer + self.transactionDetails = transactionDetails + self.config = config + self.repository = repository + } + + deinit { + confirmationTimerTask?.cancel() + pusherTask?.cancel() + requeryTask?.cancel() + } + + // MARK: - Provisioning + + @MainActor + func provisionVirtualAccount() async { + state = .loading(message: "Getting your account details") + do { + let details = try await repository.payWithTransfer( + fulfilLateNotification: config.fulfilLateNotification, + transactionId: config.transactionId, + preferredProvider: nil) + state = .awaitingPayment(details) + startListeningForPusher(on: details) + } catch { + displayTransactionError(ChargeError(error: error)) + } + } + + // MARK: - User actions + + @MainActor + func userTappedIveSentTheMoney() async { + guard case .awaitingPayment(let details) = state else { return } + transitionToConfirming(details, phase: .waitingForCredit) + + do { + let result = try await repository + .checkPendingCharge(with: transactionDetails.accessCode) + await reactToPollResult(result) + } catch { + Logger.error("PWT checkPendingCharge after I've-sent-the-money failed: %@", + arguments: error.localizedDescription) + } + } + + @MainActor + func userTappedBackToAccountNumber() { + guard let details = currentDetails else { return } + confirmationTimerTask?.cancel() + confirmationTimerTask = nil + confirmationElapsedSeconds = 0 + state = .awaitingPayment(details) + } + + @MainActor + func userTappedChangePaymentMethod() { + cancelTimers() + chargeContainer.restartFromChannelSelection() + } + + @MainActor + func userTappedGetHelp() { + guard case .takingLongerThanExpected(let details) = state else { return } + state = .delayedConfirmation(details) + } + + @MainActor + func userTappedKeepWaiting() { + guard case .delayedConfirmation(let details) = state else { return } + state = .takingLongerThanExpected(details) + } + + @MainActor + func userTappedCloseFromDelayedConfirmation() { + cancelTimers() + chargeContainer.restartFromChannelSelection() + } + + @MainActor + func userTappedChooseAnotherPaymentMethodFromRefund() { + cancelTimers() + chargeContainer.restartFromChannelSelection() + } + + @MainActor + func retryProvisioning() async { + await provisionVirtualAccount() + } + + // MARK: - Bank picker + + var availableBankSlugs: [String] { + config.availableProviders + } + + var currentBankSlug: String? { + switch state { + case .awaitingPayment(let d), + .confirmingPayment(let d, _), + .takingLongerThanExpected(let d), + .delayedConfirmation(let d), + .refundInitiated(let d, _): + return d.bankSlug + case .loading, .error, .fatalError: + return nil + } + } + + @MainActor + func userSelectedBank(slug: String) async { + Logger.info("PWT: switching to bank %@", arguments: slug) + + cancelTimers() + confirmationElapsedSeconds = 0 + + let displayName = BankTransferProviderCatalog.displayName(forSlug: slug) + state = .loading(message: "Switching to \(displayName)…") + + do { + let details = try await repository.payWithTransfer( + fulfilLateNotification: config.fulfilLateNotification, + transactionId: config.transactionId, + preferredProvider: slug) + + state = .awaitingPayment(details) + startListeningForPusher(on: details) + } catch { + Logger.error("PWT: bank switch failed: %@", + arguments: error.localizedDescription) + displayTransactionError(ChargeError(error: error)) + } + } + + // MARK: - Confirmation countdown + + private func startConfirmationCountdown() { + confirmationTimerTask?.cancel() + confirmationElapsedSeconds = 0 + let window = confirmationWindowSeconds + guard window > 0 else { return } + + confirmationTimerTask = Task { [weak self] in + for _ in 0.. Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + + header + + Divider() + .background(Color.navy05) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(availableSlugs, id: \.self) { slug in + BankPickerRow( + displayName: BankTransferProviderCatalog.displayName(forSlug: slug), + isSelected: slug == currentSlug, + onTap: { onSelect(slug) }) + Divider() + .background(Color.navy05) + } + } + } + + Spacer(minLength: 0) + } + } + + private var header: some View { + HStack { + Text("Change bank") + .font(.heading3) + .foregroundColor(.stackBlue) + Spacer() + Button(action: onCancel) { + Image(systemName: "xmark") + .foregroundColor(.navy02) + } + } + .padding(.doublePadding) + } +} + +@available(iOS 14.0, *) +private struct BankPickerRow: View { + + let displayName: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Text(displayName) + .font(.body16M) + .foregroundColor(.stackBlue) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.stackGreen) + } + } + .padding(.doublePadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift new file mode 100644 index 0000000..d33cbf1 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct BankTransferAccountDetailsView: View { + + let details: BankTransferDetails + let amount: AmountCurrency + let onChangeBank: (() -> Void)? + let onIveSentTheMoney: () async -> Void + + @State private var provisionedAt: Date = Date() + @State private var now: Date = Date() + + private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + init(details: BankTransferDetails, + amount: AmountCurrency, + onChangeBank: (() -> Void)? = nil, + onIveSentTheMoney: @escaping () async -> Void) { + self.details = details + self.amount = amount + self.onChangeBank = onChangeBank + self.onIveSentTheMoney = onIveSentTheMoney + } + + var body: some View { + VStack(spacing: .triplePadding) { + + Text("Transfer exactly \(amount.description) to") + .font(.heading3) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + accountCard + + expiryFooter + + Button("I've sent the money", action: { Task { await onIveSentTheMoney() } }) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + + } + .padding(.doublePadding) + .onReceive(tick) { newNow in + self.now = newNow + } + } + + private var accountCard: some View { + VStack(spacing: 0) { + AccountDetailRow(label: "BANK NAME", + value: details.bankName, + trailing: bankNameTrailing) + divider + AccountDetailRow(label: "ACCOUNT NUMBER", + value: details.accountNumber, + trailing: .copy) + divider + AccountDetailRow(label: "AMOUNT", + value: amount.description, + trailing: .copy) + } + .overlay( + RoundedRectangle(cornerRadius: .cornerRadius) + .stroke(Color.navy05, lineWidth: 1) + ) + } + + private var bankNameTrailing: AccountDetailRow.Trailing { + if let handler = onChangeBank { + return .text("Change bank", handler) + } + return .none + } + + private var divider: some View { + Rectangle() + .fill(Color.navy05) + .frame(height: 1) + } + + private var expiryFooter: some View { + VStack(spacing: .singlePadding) { + Text("This account is for one-time use only and expires in") + .font(.body14R) + .foregroundColor(.navy03) + .multilineTextAlignment(.center) + Text(formattedRemaining) + .font(.body14M) + .foregroundColor(.stackGreen) + + ProgressView(value: progress) + .progressViewStyle(.linear) + .accentColor(.stackGreen) + } + } + + private var remainingSeconds: Int { + max(0, Int(details.accountExpiresAt.timeIntervalSince(now))) + } + + private var formattedRemaining: String { + let minutes = remainingSeconds / 60 + let seconds = remainingSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private var progress: Double { + let total = details.accountExpiresAt.timeIntervalSince(provisionedAt) + guard total > 0 else { return 0 } + let elapsed = max(0, now.timeIntervalSince(provisionedAt)) + return min(1.0, elapsed / total) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift new file mode 100644 index 0000000..7fc8a27 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift @@ -0,0 +1,102 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14.0, *) +struct BankTransferConfirmingView: View { + + let details: BankTransferDetails + + let phase: ConfirmingPhase + + let confirmationWindowSeconds: Int + let elapsedSeconds: Int + let onBackToAccountNumber: () -> Void + + private var receivedByBackend: Bool { + phase == .transferOnTheWay + } + + private var bodyCopy: String { + switch phase { + case .waitingForCredit: + return "We're waiting to confirm your transfer. This can take a few minutes" + case .transferOnTheWay: + return "Your transfer is on the way…" + } + } + + private var remainingSeconds: Int { + max(0, confirmationWindowSeconds - elapsedSeconds) + } + + private var formattedRemaining: String { + let minutes = remainingSeconds / 60 + let seconds = remainingSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + var body: some View { + VStack(spacing: .triplePadding) { + + Text(bodyCopy) + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + .animation(.easeInOut(duration: 0.25), value: phase) + + timeline + + Button("Please wait \(formattedRemaining) minutes", action: {}) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + .disabled(true) + + Button("Back to account number", action: onBackToAccountNumber) + .foregroundColor(.navy02) + .font(.body14M) + } + .padding(.doublePadding) + .onChange(of: phase) { newPhase in + guard newPhase == .transferOnTheWay else { return } + triggerReceivedHaptic() + } + } + + private func triggerReceivedHaptic() { + #if canImport(UIKit) + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + #endif + } + + // MARK: - Subviews + + private var timeline: some View { + HStack(spacing: 0) { + TimelineNode(label: "Sent", state: .complete) + connectingLine + .padding(.horizontal, .singlePadding) + TimelineNode(label: "Received", + state: receivedByBackend ? .complete : .pending) + } + .padding(.horizontal, .triplePadding) + } + + private var connectingLine: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.gray01) + .frame(height: 1) + Rectangle() + .fill(Color.stackGreen) + .frame(width: receivedByBackend ? geo.size.width : 0, + height: 1) + .animation(.easeInOut(duration: 0.45), value: receivedByBackend) + } + .frame(maxHeight: .infinity, alignment: .center) + } + .frame(height: 1) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift new file mode 100644 index 0000000..3d917e6 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct BankTransferDelayedConfirmationView: View { + + let supportEmail: String + let onClose: () -> Void + let onKeepWaiting: () -> Void + + var body: some View { + VStack(spacing: .triplePadding) { + + Text("We'll complete this transaction automatically once we confirm your transfer.") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + Text("If you have any issues with this transfer, please contact us via \(supportEmail) with the following details:") + .font(.body14R) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: .singlePadding) { + NumberedListRow(index: 1, text: "Recipient Account") + NumberedListRow(index: 2, text: "Sender Account") + NumberedListRow(index: 3, text: "Amount Paid") + NumberedListRow(index: 4, text: "Date of Payment") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, .doublePadding) + + Button("Close", action: onClose) + .buttonStyle(SecondaryButtonStyle()) + + Button("Keep waiting", action: onKeepWaiting) + .foregroundColor(.navy02) + .font(.body14M) + } + .padding(.doublePadding) + } +} + +@available(iOS 14.0, *) +private struct NumberedListRow: View { + let index: Int + let text: String + + var body: some View { + HStack(spacing: .singlePadding) { + Text("\(index).") + .font(.body14R) + .foregroundColor(.stackBlue) + Text(text) + .font(.body14R) + .foregroundColor(.stackBlue) + } + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferRefundInitiatedView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferRefundInitiatedView.swift new file mode 100644 index 0000000..3132e24 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferRefundInitiatedView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct BankTransferRefundInitiatedView: View { + + let message: String + let transactionReference: String + let onChooseAnotherPaymentMethod: () -> Void + + @EnvironmentObject + var visibilityContainer: ViewVisibilityContainer + + var body: some View { + VStack(spacing: .triplePadding) { + + Image.errorIcon + + Text("Refund initiated") + .font(.heading2) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + Text(message) + .font(.body14R) + .foregroundColor(.navy02) + .multilineTextAlignment(.center) + .padding(.horizontal, .singlePadding) + + Button("Choose another payment method", + action: onChooseAnotherPaymentMethod) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + + Button("Close", action: dismissWithError) + .foregroundColor(.navy02) + .font(.body14M) + } + .padding(.doublePadding) + } + + private func dismissWithError() { + let chargeError = ChargeError(message: message) + visibilityContainer.completeAndDismiss( + with: .error(error: chargeError, reference: transactionReference)) + } +} + +@available(iOS 14.0, *) +struct BankTransferRefundInitiatedView_Previews: PreviewProvider { + static var previews: some View { + BankTransferRefundInitiatedView( + message: "You sent an incorrect amount. We've started a refund — funds will be returned to the account you sent from within a few business days.", + transactionReference: "T6215047322I100043S0g703", + onChooseAnotherPaymentMethod: {}) + .environmentObject(ViewVisibilityContainer(onComplete: { _ in })) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift new file mode 100644 index 0000000..57093e8 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct BankTransferTakingLongerView: View { + + let details: BankTransferDetails + let onGetHelp: () -> Void + let onBackToAccountNumber: () -> Void + + var body: some View { + VStack(spacing: .triplePadding) { + + Text("It's taking longer than expected to confirm your transfer. " + + "You don't have to wait here till we confirm it.") + .font(.body16M) + .foregroundColor(.stackBlue) + .multilineTextAlignment(.center) + + timeline + + Button("Get help", action: onGetHelp) + .buttonStyle(PrimaryButtonStyle(showLoading: false)) + + Button("Back to account number", action: onBackToAccountNumber) + .foregroundColor(.navy02) + .font(.body14M) + } + .padding(.doublePadding) + } + + private var timeline: some View { + HStack(spacing: 0) { + TimelineNode(label: "Sent", state: .complete) + Rectangle() + .fill(Color.gray01) + .frame(height: 1) + .padding(.horizontal, .singlePadding) + TimelineNode(label: "Received", state: .pending) + } + .padding(.horizontal, .triplePadding) + } +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift new file mode 100644 index 0000000..2cb9ff4 --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct BankTransferView: View { + + @StateObject + var viewModel: BankTransferViewModel + + @State private var showBankPicker = false + + init(chargeContainer: ChargeContainer, + transactionDetails: VerifyAccessCode, + config: BankTransferConfig) { + self._viewModel = StateObject(wrappedValue: BankTransferViewModel( + 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): + BankTransferAccountDetailsView( + details: details, + amount: viewModel.transactionDetails.amountCurrency, + onChangeBank: viewModel.availableBankSlugs.isEmpty + ? nil + : { showBankPicker = true }, + onIveSentTheMoney: { await viewModel.userTappedIveSentTheMoney() }) + .id(details.transactionReference) + case .confirmingPayment(let details, let phase): + BankTransferConfirmingView( + details: details, + phase: phase, + confirmationWindowSeconds: viewModel.confirmationWindowSeconds, + elapsedSeconds: viewModel.confirmationElapsedSeconds, + onBackToAccountNumber: viewModel.userTappedBackToAccountNumber) + case .takingLongerThanExpected(let details): + BankTransferTakingLongerView( + details: details, + onGetHelp: viewModel.userTappedGetHelp, + onBackToAccountNumber: viewModel.userTappedBackToAccountNumber) + case .delayedConfirmation: + BankTransferDelayedConfirmationView( + supportEmail: "support@paystack.com", + onClose: viewModel.userTappedCloseFromDelayedConfirmation, + onKeepWaiting: viewModel.userTappedKeepWaiting) + case .refundInitiated(_, let message): + BankTransferRefundInitiatedView( + message: message, + transactionReference: viewModel.transactionDetails.reference, + onChooseAnotherPaymentMethod: + viewModel.userTappedChooseAnotherPaymentMethodFromRefund) + case .error(let error): + ErrorView(message: error.message, + buttonText: "Try again", + buttonAction: { Task { await viewModel.retryProvisioning() } }) + case .fatalError(let error): + ErrorView(message: error.message, + automaticallyDismissWith: .init( + error: error, + transactionReference: viewModel.transactionDetails.reference)) + } + + if showsChangePaymentMethodFooter { + ChangePaymentMethodFooter(action: viewModel.userTappedChangePaymentMethod) + } + } + .task(viewModel.provisionVirtualAccount) + .sheet(isPresented: $showBankPicker) { + BankPickerSheet( + availableSlugs: viewModel.availableBankSlugs, + currentSlug: viewModel.currentBankSlug, + onSelect: { slug in + Task { @MainActor [viewModel] in + await viewModel.userSelectedBank(slug: slug) + } + showBankPicker = false + }, + onCancel: { showBankPicker = false }) + } + } + + private var showsChangePaymentMethodFooter: Bool { + switch viewModel.state { + case .awaitingPayment, .confirmingPayment, + .takingLongerThanExpected, .delayedConfirmation: + return true + case .loading, .error, .fatalError, .refundInitiated: + return false + } + } +} diff --git a/Sources/PaystackUI/Charge/ChargeContainer.swift b/Sources/PaystackUI/Charge/ChargeContainer.swift index 93e1711..f9c8a93 100644 --- a/Sources/PaystackUI/Charge/ChargeContainer.swift +++ b/Sources/PaystackUI/Charge/ChargeContainer.swift @@ -2,4 +2,5 @@ import Foundation protocol ChargeContainer { func processSuccessfulTransaction(details: VerifyAccessCode) + func restartFromChannelSelection() } diff --git a/Sources/PaystackUI/Charge/ChargeView.swift b/Sources/PaystackUI/Charge/ChargeView.swift index 749ccfa..1e7b5b8 100644 --- a/Sources/PaystackUI/Charge/ChargeView.swift +++ b/Sources/PaystackUI/Charge/ChargeView.swift @@ -29,7 +29,6 @@ struct ChargeView: View { case .loading(let message): LoadingView(message: message) case .error(let error): - // TODO: Update once we have new designs for this error screen ErrorView(message: error.message) case .payment(let type): paymentFlowView(for: type) @@ -63,6 +62,10 @@ struct ChargeView: View { MobileMoneyFlowFactory.view(for: provider, chargeContainer: viewModel, transactionDetails: transactionInformation) + case .bankTransfer(let transactionInformation, let config): + BankTransferView(chargeContainer: viewModel, + transactionDetails: transactionInformation, + config: config) } } diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index a902717..0f3b291 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -39,7 +39,6 @@ class ChargeViewModel: ObservableObject { let error = ChargeError(error: error) Logger.error("Verify access code failed with error: %@", arguments: error.cause?.localizedDescription ?? "Unknown") - // TODO: Display a fatal error here in the future instead transactionState = .error(error) } } @@ -57,6 +56,16 @@ class ChargeViewModel: ObservableObject { result.append(contentsOf: allowed.map { .mobileMoney($0) }) } + if response.paymentChannels.contains(.bankTransfer), + let transactionId = response.transactionId { + let config = BankTransferConfig( + fulfilLateNotification: response + .merchantChannelSettings?.bankTransfer?.fulfilLateNotification ?? false, + transactionId: transactionId, + availableProviders: response.channelOptions?.bankTransfer ?? []) + result.append(.bankTransfer(config)) + } + return result } @@ -80,29 +89,26 @@ class ChargeViewModel: ObservableObject { provider: provider)) } + if !channels.contains(.card), + channels.count == 1, + case .bankTransfer(let config) = channels[0] { + return .payment(type: .bankTransfer(transactionInformation: response, + config: config)) + } + return .channelSelection(transactionInformation: response, supportedChannels: channels) } } -// MARK: - Mobile money provider allowlist extension ChargeViewModel { - /// Mobile money provider keys (`MobileMoneyChannel.key`, uppercased) that - /// the SDK is allowed to route to. The API may return providers we don't - /// yet have logos, copy, or phone formatters for — listing them here is - /// what opts them into the UI. - /// - /// Set to `nil` to accept every provider the API returns (no filtering). - /// Add a new key here when you've added its logo to `SupportedChannel.image` - /// and its country code / phone formatter to the relevant helpers. static var supportedMobileMoneyProviders: Set? = [ - "MPESA", "ATL_KE", "MTN", "ATL", "VOD" + "MPESA", "ATL_KE", "MTN", "ATL", "VOD", "WAVE_CI", "ORANGE_CI", "MTN_CI" ] } -// MARK: - Charge Container extension ChargeViewModel: ChargeContainer { func processSuccessfulTransaction(details: VerifyAccessCode) { @@ -111,9 +117,19 @@ extension ChargeViewModel: ChargeContainer { details: .init(reference: details.reference)) } + func restartFromChannelSelection() { + guard let details = transactionDetails else { + Logger.error("restartFromChannelSelection called without cached transaction details") + transactionState = .error(.generic) + return + } + let supported = resolveSupportedChannels(from: details) + transactionState = .channelSelection(transactionInformation: details, + supportedChannels: supported) + } + } -// MARK: UI State Management extension ChargeViewModel { var centerView: Bool { diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift index 76f48d2..5591579 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift @@ -1,10 +1,3 @@ -// -// ChannelSelectionView.swift -// PaystackUI -// -// Created by Peter-John Welcome on 2023/12/01. -// - import SwiftUI import PaystackCore @available(iOS 14.0, *) @@ -65,6 +58,9 @@ class ChannelSelectionViewModel: ObservableObject { case .mobileMoney(let provider): state = .payment(type: .mobileMoney(transactionInformation: self.information, provider: provider)) + case .bankTransfer(let config): + state = .payment(type: .bankTransfer(transactionInformation: self.information, + config: config)) } } } diff --git a/Sources/PaystackUI/Charge/Models/ChannelOptions.swift b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift index 7e49cf4..2a626ce 100644 --- a/Sources/PaystackUI/Charge/Models/ChannelOptions.swift +++ b/Sources/PaystackUI/Charge/Models/ChannelOptions.swift @@ -3,14 +3,15 @@ import PaystackCore struct ChannelOptions: Equatable { var mobileMoney: [MobileMoneyChannel]? + var bankTransfer: [String]? } extension ChannelOptions { static func from(_ response: PaystackCore.ChannelOptions) -> Self { - return ChannelOptions(mobileMoney: response.mobileMoney?.map({ - MobileMoneyChannel.from($0) - })) + return ChannelOptions( + mobileMoney: response.mobileMoney?.map({ MobileMoneyChannel.from($0) }), + bankTransfer: response.bankTransfer) } } diff --git a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift index 53de317..c070221 100644 --- a/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift +++ b/Sources/PaystackUI/Charge/Models/ChargePaymentType.swift @@ -1,8 +1,9 @@ import Foundation -// TODO: Add an extension to map from payment channels once those are defined enum ChargePaymentType: Equatable { case card(transactionInformation: VerifyAccessCode) case mobileMoney(transactionInformation: VerifyAccessCode, provider: MobileMoneyChannel) + case bankTransfer(transactionInformation: VerifyAccessCode, + config: BankTransferConfig) } diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index dd87e26..66b6d30 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -1,12 +1,10 @@ import SwiftUI import PaystackCore -/// A resolved payment channel that the SDK is willing to route to for the -/// current transaction. One entry per card option, plus one entry per -/// supported mobile money provider returned by `verifyAccessCode`. enum SupportedChannel: Equatable, Identifiable { case card case mobileMoney(MobileMoneyChannel) + case bankTransfer(BankTransferConfig) var id: String { switch self { @@ -14,6 +12,8 @@ enum SupportedChannel: Equatable, Identifiable { return "card" case .mobileMoney(let channel): return "mobile_money.\(channel.key)" + case .bankTransfer: + return "bank_transfer" } } @@ -23,6 +23,8 @@ enum SupportedChannel: Equatable, Identifiable { return "Card" case .mobileMoney(let channel): return channel.value + case .bankTransfer: + return "Transfer" } } @@ -32,13 +34,11 @@ enum SupportedChannel: Equatable, Identifiable { return Image("cardLogo", bundle: .current) case .mobileMoney(let channel): return Self.image(forMobileMoneyKey: channel.key) + case .bankTransfer: + return Image(systemName: "building.columns") } } - /// Maps known Paystack mobile money provider keys to a bundled logo. - /// Falls back to a generic SF Symbol when the SDK has no logo for the - /// provider yet — keeps the channel-selection screen renderable when a - /// future provider lights up via the allowlist. private static func image(forMobileMoneyKey key: String) -> Image { switch key.uppercased() { case "MPESA", "ATL_KE": diff --git a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift index 4c42a10..9774c21 100644 --- a/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift +++ b/Sources/PaystackUI/Charge/Models/VerifyAccessCode.swift @@ -12,6 +12,7 @@ struct VerifyAccessCode: Equatable { var reference: String var transactionId: Int? var channelOptions: PaystackUI.ChannelOptions? + var merchantChannelSettings: MerchantChannelSettings? var amountCurrency: AmountCurrency { AmountCurrency(amount: amount, currency: currency) @@ -29,12 +30,13 @@ extension VerifyAccessCode { merchantName: response.data.merchantName, publicEncryptionKey: response.data.publicEncryptionKey, reference: response.data.reference, - transactionId: response.data.id, channelOptions: PaystackUI.ChannelOptions.from(response.data.channelOptions)) + transactionId: response.data.id, + channelOptions: PaystackUI.ChannelOptions.from(response.data.channelOptions), + merchantChannelSettings: response.data.merchantChannelSettings) } } -// MARK: - Previews extension VerifyAccessCode { static var example: Self { .init(amount: 10000, diff --git a/Sources/PaystackUI/Components/AccountDetailRow.swift b/Sources/PaystackUI/Components/AccountDetailRow.swift new file mode 100644 index 0000000..d6045cc --- /dev/null +++ b/Sources/PaystackUI/Components/AccountDetailRow.swift @@ -0,0 +1,74 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct AccountDetailRow: View { + + enum Trailing { + case none + case copy + case text(String, () -> Void) + } + + let label: String + let value: String + let trailing: Trailing + + @State private var justCopied = false + + init(label: String, value: String, trailing: Trailing = .none) { + self.label = label + self.value = value + self.trailing = trailing + } + + var body: some View { + HStack(alignment: .center, spacing: .doublePadding) { + VStack(alignment: .leading, spacing: .quarterPadding) { + Text(label) + .font(.body12M) + .foregroundColor(.navy03) + .padding(.bottom, .singlePadding) + Text(value) + .font(.body16M) + .foregroundColor(.stackBlue) + } + Spacer() + trailingView + } + .padding(.doublePadding) + } + + @ViewBuilder + private var trailingView: some View { + switch trailing { + case .none: + EmptyView() + case .copy: + #if os(iOS) + Button(action: copy) { + Image(systemName: justCopied ? "checkmark" : "doc.on.doc") + .foregroundColor(justCopied ? .stackGreen : .navy02) + } + #else + EmptyView() + #endif + case .text(let title, let action): + Button(title, action: action) + .font(.body14M) + .foregroundColor(.stackGreen) + } + } + + #if os(iOS) + private func copy() { + UIPasteboard.general.string = value + withAnimation { justCopied = true } + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + await MainActor.run { + withAnimation { justCopied = false } + } + } + } + #endif +} diff --git a/Sources/PaystackUI/Components/ChangePaymentMethodFooter.swift b/Sources/PaystackUI/Components/ChangePaymentMethodFooter.swift new file mode 100644 index 0000000..83e7ac6 --- /dev/null +++ b/Sources/PaystackUI/Components/ChangePaymentMethodFooter.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct ChangePaymentMethodFooter: View { + + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: .singlePadding) { + Image(systemName: "arrow.triangle.2.circlepath") + Text("Change payment method") + } + .font(.body14M) + .foregroundColor(.navy02) + .padding(.horizontal, .doublePadding) + .padding(.vertical, .singlePadding) + .overlay( + Capsule().stroke(Color.navy05, lineWidth: 1) + ) + } + .padding(.bottom, .doublePadding) + } +} diff --git a/Sources/PaystackUI/Components/TimelineNode.swift b/Sources/PaystackUI/Components/TimelineNode.swift new file mode 100644 index 0000000..fc1f552 --- /dev/null +++ b/Sources/PaystackUI/Components/TimelineNode.swift @@ -0,0 +1,75 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct TimelineNode: View { + + enum NodeState: Equatable { + case complete + case pending + } + + let label: String + let state: NodeState + + private let diameter: CGFloat = 28 + + @State private var nodeScale: CGFloat = 1.0 + @State private var checkmarkScale: CGFloat = 1.0 + @State private var rippleScale: CGFloat = 1.0 + @State private var rippleOpacity: Double = 0.0 + + var body: some View { + VStack(spacing: .singlePadding) { + ZStack { + Circle() + .stroke(Color.stackGreen, lineWidth: 2) + .frame(width: diameter, height: diameter) + .scaleEffect(rippleScale) + .opacity(rippleOpacity) + + switch state { + case .complete: + Circle() + .fill(Color.stackGreen) + .frame(width: diameter, height: diameter) + .scaleEffect(nodeScale) + Image(systemName: "checkmark") + .foregroundColor(.white) + .font(.system(size: 14, weight: .bold)) + .scaleEffect(checkmarkScale) + case .pending: + Circle() + .stroke(style: StrokeStyle(lineWidth: 2, dash: [3, 3])) + .foregroundColor(.gray01) + .frame(width: diameter, height: diameter) + } + } + Text(label) + .font(.body12M) + .foregroundColor(state == .complete ? .stackGreen : .navy03) + .animation(.easeInOut(duration: 0.2), value: state) + } + .onChange(of: state) { newState in + guard newState == .complete else { return } + playCompletionAnimation() + } + } + + private func playCompletionAnimation() { + nodeScale = 0.4 + checkmarkScale = 0.0 + rippleScale = 1.0 + rippleOpacity = 0.7 + + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { + nodeScale = 1.0 + } + withAnimation(.spring(response: 0.3, dampingFraction: 0.55).delay(0.12)) { + checkmarkScale = 1.0 + } + withAnimation(.easeOut(duration: 0.8)) { + rippleScale = 2.4 + rippleOpacity = 0.0 + } + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/PayWithTransferTests.swift b/Tests/PaystackSDKTests/API/Charge/PayWithTransferTests.swift new file mode 100644 index 0000000..ede8a55 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/PayWithTransferTests.swift @@ -0,0 +1,186 @@ +import XCTest +@testable import PaystackCore + +final class PayWithTransferTests: PSTestCase { + + let apiKey = "testsk_Example" + + var serviceUnderTest: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + serviceUnderTest = try PaystackBuilder.newInstance + .setKey(apiKey) + .build() + } + + func testPayWithTransferSubmitsRequestToCorrectURLAndMethodAndHeaders() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + let request = PayWithTransferRequest(fulfilLateNotification: true, + transactionId: 6215047322, + preferredProvider: nil) + let result = try await serviceUnderTest.payWithTransfer(request).async() + XCTAssertEqual(result, .jsonExample) + } + + func testPayWithTransferDecodesAllFieldsFromResponse() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + let request = PayWithTransferRequest(fulfilLateNotification: false, + transactionId: 6215047322) + let result = try await serviceUnderTest.payWithTransfer(request).async() + + XCTAssertTrue(result.status) + XCTAssertEqual(result.message, "Please make a transfer to the account specified") + XCTAssertEqual(result.data.accountName, "PAYSTACK CHECKOUT") + XCTAssertEqual(result.data.accountNumber, "9985488398") + XCTAssertEqual(result.data.transactionReference, "T6215047322I100043S0g703") + XCTAssertEqual(result.data.transactionId, "6215047322") + XCTAssertEqual(result.data.pusherChannel, "PWT6215047322") + XCTAssertEqual(result.data.bank.slug, "titan-paystack") + XCTAssertEqual(result.data.bank.name, "Paystack-Titan") + XCTAssertEqual(result.data.bank.id, 629) + } + + func testPayWithTransferDecodesDateFieldsUsingPaystackFormatter() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + let request = PayWithTransferRequest(fulfilLateNotification: false, + transactionId: 6215047322) + let result = try await serviceUnderTest.payWithTransfer(request).async() + + let expectedAccountExpiry = DateFormatter.paystackFormatter + .date(from: "2026-06-02T15:18:37.053Z") + let expectedAssignmentExpiry = DateFormatter.paystackFormatter + .date(from: "2026-06-02T22:48:37.053Z") + XCTAssertEqual(result.data.accountExpiresAt, expectedAccountExpiry) + XCTAssertEqual(result.data.assignmentExpiresAt, expectedAssignmentExpiry) + } + + func testListenForTransferResponseSubscribesToProvidedChannelWithResponseEvent() async throws { + let channelName = "PWT6215047322" + let mockSubscription = PusherSubscription(channelName: channelName, + eventName: "response") + mockSubscriptionListener + .expectSubscription(mockSubscription) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + XCTAssertEqual(result.status, "success") + } + + func testListenForTransferResponseDecodesSuccessEvent() async throws { + let channelName = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.message, "Payment Successful") + XCTAssertEqual(result.data?.messageType, "SUCCESS") + XCTAssertEqual(result.data?.transactionId, "3818017015") + XCTAssertEqual(result.data?.reference, "T3818017015I615243Sujjxh") + XCTAssertEqual(result.data?.trxref, "T3818017015I615243Sujjxh") + XCTAssertEqual(result.data?.trans, "3818017015") + XCTAssertEqual(result.data?.response, "Approved") + XCTAssertEqual(result.data?.redirecturl, "") + XCTAssertNil(result.errors) + } + + func testListenForTransferResponseDecodesCreditRequestReceivedEvent() async throws { + let channelName = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditReceived") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "transfer-credit-request-received") + XCTAssertEqual(result.message, "credit request received") + XCTAssertEqual(result.data?.messageType, "TRANSFER_CREDIT_REQUEST_RECEIVED") + XCTAssertEqual(result.data?.referenceId, "3818017015") + XCTAssertEqual(result.data?.transactionId, "3818017015") + } + + func testListenForTransferResponseDecodesCreditRequestPendingEvent() async throws { + let channelName = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditPending") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "transfer-credit-request-pending") + XCTAssertEqual(result.data?.messageType, "TRANSFER_CREDIT_REQUEST_PENDING") + } + + func testListenForTransferResponseDecodesCreditRequestRejectedEvent() async throws { + let channelName = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditRejected") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "transfer-credit-request-rejected") + XCTAssertEqual(result.message, "Transfer was rejected") + XCTAssertEqual(result.data?.messageType, "TRANSFER_CREDIT_REQUEST_REJECTED") + } + + func testListenForTransferResponseDecodesIncorrectAmountSentEvent() async throws { + let channelName = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherIncorrectAmount") + + let result = try await serviceUnderTest + .listenForTransferResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, "failed") + XCTAssertEqual(result.message, "incorrect amount sent") + XCTAssertNil(result.data) + XCTAssertNotNil(result.errors) + XCTAssertEqual(result.errors?.isEmpty, true) + } +} + +private extension PayWithTransferResponse { + static var jsonExample: PayWithTransferResponse { + PayWithTransferResponse( + status: true, + message: "Please make a transfer to the account specified", + data: PayWithTransferData( + accountName: "PAYSTACK CHECKOUT", + accountNumber: "9985488398", + transactionReference: "T6215047322I100043S0g703", + bank: TransferBank(slug: "titan-paystack", + name: "Paystack-Titan", + id: 629), + accountExpiresAt: DateFormatter.paystackFormatter + .date(from: "2026-06-02T15:18:37.053Z")!, + assignmentExpiresAt: DateFormatter.paystackFormatter + .date(from: "2026-06-02T22:48:37.053Z")!, + transactionId: "6215047322", + pusherChannel: "PWT6215047322")) + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditPending.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditPending.json new file mode 100644 index 0000000..96fc5cb --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditPending.json @@ -0,0 +1,9 @@ +{ + "status": "transfer-credit-request-pending", + "message": "credit request pending", + "data": { + "message_type": "TRANSFER_CREDIT_REQUEST_PENDING", + "reference_id": "3818017015", + "transaction_id": "3818017015" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditReceived.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditReceived.json new file mode 100644 index 0000000..19f87dc --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditReceived.json @@ -0,0 +1,9 @@ +{ + "status": "transfer-credit-request-received", + "message": "credit request received", + "data": { + "message_type": "TRANSFER_CREDIT_REQUEST_RECEIVED", + "reference_id": "3818017015", + "transaction_id": "3818017015" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditRejected.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditRejected.json new file mode 100644 index 0000000..883b2e8 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherCreditRejected.json @@ -0,0 +1,9 @@ +{ + "status": "transfer-credit-request-rejected", + "message": "Transfer was rejected", + "data": { + "message_type": "TRANSFER_CREDIT_REQUEST_REJECTED", + "reference_id": "3818017015", + "transaction_id": "3818017015" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json new file mode 100644 index 0000000..642f415 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json @@ -0,0 +1,5 @@ +{ + "status": "failed", + "message": "incorrect amount sent", + "errors": [] +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherSuccess.json new file mode 100644 index 0000000..00c89bc --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferPusherSuccess.json @@ -0,0 +1,13 @@ +{ + "status": "success", + "message": "Payment Successful", + "data": { + "message_type": "SUCCESS", + "transaction_id": "3818017015", + "reference": "T3818017015I615243Sujjxh", + "trxref": "T3818017015I615243Sujjxh", + "trans": "3818017015", + "response": "Approved", + "redirecturl": "" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferResponse.json new file mode 100644 index 0000000..b8a7554 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/PayWithTransferResponse.json @@ -0,0 +1,18 @@ +{ + "status": true, + "message": "Please make a transfer to the account specified", + "data": { + "account_name": "PAYSTACK CHECKOUT", + "account_number": "9985488398", + "transaction_reference": "T6215047322I100043S0g703", + "bank": { + "slug": "titan-paystack", + "name": "Paystack-Titan", + "id": 629 + }, + "account_expires_at": "2026-06-02T15:18:37.053Z", + "assignment_expires_at": "2026-06-02T22:48:37.053Z", + "transaction_id": "6215047322", + "pusher_channel": "PWT6215047322" + } +} diff --git a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json index aea7d2d..945b03f 100644 --- a/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json +++ b/Tests/PaystackSDKTests/API/Transactions/Resources/VerifyAccessCode.json @@ -22,6 +22,11 @@ "qr": [ "visa" ], + "bank_transfer": [ + "wema-bank", + "titan-paystack", + "paystack-mfb" + ], "mobile_money": [ { "key": "MPESA", @@ -44,6 +49,11 @@ "919_GH", "mcash" ] + }, + "merchant_channel_settings": { + "bank_transfer": { + "fulfil_late_notification": true + } } } } diff --git a/Tests/PaystackSDKTests/Core/PaystackFormatterTests.swift b/Tests/PaystackSDKTests/Core/PaystackFormatterTests.swift new file mode 100644 index 0000000..cd100e6 --- /dev/null +++ b/Tests/PaystackSDKTests/Core/PaystackFormatterTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import PaystackCore + +final class PaystackFormatterTests: XCTestCase { + + func testParsesIso8601UtcStringAsAbsoluteUtcTime() throws { + let parsed = DateFormatter.paystackFormatter.date(from: "2024-01-01T12:00:00.000Z") + + let unwrapped = try XCTUnwrap(parsed) + XCTAssertEqual(unwrapped.timeIntervalSince1970, 1704110400, accuracy: 0.001) + } + + func testParsesFractionalSecondsCorrectly() throws { + let parsed = DateFormatter.paystackFormatter.date(from: "2024-01-01T12:00:00.500Z") + + let unwrapped = try XCTUnwrap(parsed) + XCTAssertEqual(unwrapped.timeIntervalSince1970, 1704110400.5, accuracy: 0.001) + } + + func testRoundTripsADate() throws { + let original = Date(timeIntervalSince1970: 1704110400) + let encoded = DateFormatter.paystackFormatter.string(from: original) + let decoded = DateFormatter.paystackFormatter.date(from: encoded) + + let unwrapped = try XCTUnwrap(decoded) + XCTAssertEqual(unwrapped.timeIntervalSince1970, + original.timeIntervalSince1970, + accuracy: 0.001) + } + + func testParsesPayWithTransferAccountExpiryAsFutureInstant() { + let parsed = DateFormatter.paystackFormatter.date(from: "2026-06-02T15:18:37.053Z") + + XCTAssertNotNil(parsed) + let june1 = Date(timeIntervalSince1970: 1748736000) + XCTAssertGreaterThan(parsed!.timeIntervalSince(june1), 0) + } + + func testParsedExpiryDifferenceIsTimezoneIndependent() { + let provisioned = DateFormatter.paystackFormatter.date(from: "2024-01-01T12:00:00.000Z") + let expires = DateFormatter.paystackFormatter.date(from: "2024-01-01T12:30:00.000Z") + + XCTAssertNotNil(provisioned) + XCTAssertNotNil(expires) + XCTAssertEqual(expires!.timeIntervalSince(provisioned!), 30 * 60, accuracy: 0.001) + } + + func testReturnsNilForUnparseableString() { + XCTAssertNil(DateFormatter.paystackFormatter.date(from: "not a date")) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferProviderCatalogTests.swift b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferProviderCatalogTests.swift new file mode 100644 index 0000000..80b09fc --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferProviderCatalogTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import PaystackUI + +final class BankTransferProviderCatalogTests: XCTestCase { + + func testDisplayNameForWemaBankSlug() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "wema-bank"), + "Wema Bank") + } + + func testDisplayNameForTitanPaystackSlug() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "titan-paystack"), + "Paystack-Titan") + } + + func testDisplayNameForPaystackMfbSlug() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "paystack-mfb"), + "Paystack MFB") + } + + func testDisplayNameIsCaseInsensitiveOnKnownSlugs() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "WEMA-BANK"), + "Wema Bank") + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "Titan-Paystack"), + "Paystack-Titan") + } + + func testDisplayNameForUnknownSingleWordSlug() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "kuda"), + "Kuda") + } + + func testDisplayNameForUnknownMultiWordSlug() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "new-bank-here"), + "New Bank Here") + } + + func testDisplayNameForEmptySlugReturnsEmptyString() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: ""), + "") + } + + func testDisplayNameStripsExtraDashes() { + XCTAssertEqual( + BankTransferProviderCatalog.displayName(forSlug: "fancy--bank"), + "Fancy Bank") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferStatusTests.swift b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferStatusTests.swift new file mode 100644 index 0000000..35628d5 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferStatusTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import PaystackUI + +final class BankTransferStatusTests: XCTestCase { + + // MARK: - init(rawStatus:message:) — documented status strings + + func testInitMapsSuccessString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "success", message: nil), + .success) + } + + func testInitMapsTransferCreditRequestPendingString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "transfer-credit-request-pending", message: nil), + .creditRequestPending) + } + + func testInitMapsTransferCreditRequestReceivedString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "transfer-credit-request-received", message: nil), + .creditRequestReceived) + } + + func testInitMapsTransferCreditRequestRejectedString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "transfer-credit-request-rejected", message: nil), + .creditRequestRejected) + } + + func testInitMapsIncorrectAmountSentString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "incorrect-amount-sent", message: nil), + .incorrectAmountSent) + } + + func testInitMapsPendingString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "pending", message: nil), + .pending) + } + + func testInitMapsRequeryString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "requery", message: nil), + .requery) + } + + func testInitMapsFailedString() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "failed", message: nil), + .failed) + } + + // MARK: - init(rawStatus:message:) — message field is ignored for disambiguation + + /// Regression: the legacy decoder used to route `failed` to + /// `.incorrectAmount` when the message contained "incorrect amount". + /// The new taxonomy maps `failed` directly to `.failed` regardless of + /// message content — `incorrect-amount-sent` is the explicit status + /// for the refund-initiated wrong-amount case. + func testInitMapsFailedWithIncorrectAmountMessageToFailedNotIncorrectAmount() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "failed", message: "incorrect amount sent"), + .failed) + } + + func testInitMapsFailedWithArbitraryMessageToFailed() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "failed", message: "some other error"), + .failed) + } + + // MARK: - init(rawStatus:message:) — unknown strings + + func testInitWrapsUnknownRawStringInUnknownCase() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "something-new-from-backend", message: nil), + .unknown("something-new-from-backend")) + } + + func testInitWrapsEmptyStringInUnknownCase() { + XCTAssertEqual( + BankTransferStatus(rawStatus: "", message: nil), + .unknown("")) + } + + // MARK: - isTerminal + + func testIsTerminalIsTrueForSuccess() { + XCTAssertTrue(BankTransferStatus.success.isTerminal) + } + + func testIsTerminalIsTrueForCreditRequestRejected() { + XCTAssertTrue(BankTransferStatus.creditRequestRejected.isTerminal) + } + + /// `incorrectAmountSent` flipped from non-terminal to terminal in the + /// new taxonomy — the wrong-amount case is refund-initiated, not a + /// retry-with-top-up case any more. + func testIsTerminalIsTrueForIncorrectAmountSent() { + XCTAssertTrue(BankTransferStatus.incorrectAmountSent.isTerminal) + } + + func testIsTerminalIsTrueForFailed() { + XCTAssertTrue(BankTransferStatus.failed.isTerminal) + } + + func testIsTerminalIsFalseForCreditRequestReceived() { + XCTAssertFalse(BankTransferStatus.creditRequestReceived.isTerminal) + } + + func testIsTerminalIsFalseForCreditRequestPending() { + XCTAssertFalse(BankTransferStatus.creditRequestPending.isTerminal) + } + + func testIsTerminalIsFalseForPending() { + XCTAssertFalse(BankTransferStatus.pending.isTerminal) + } + + func testIsTerminalIsFalseForRequery() { + XCTAssertFalse(BankTransferStatus.requery.isTerminal) + } + + func testIsTerminalIsFalseForUnknown() { + XCTAssertFalse(BankTransferStatus.unknown("anything").isTerminal) + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift new file mode 100644 index 0000000..f7e5ec8 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift @@ -0,0 +1,787 @@ +import XCTest +import PaystackCore +@testable import PaystackUI + +final class BankTransferViewModelTests: XCTestCase { + + var serviceUnderTest: BankTransferViewModel! + var mockChargeContainer: MockChargeContainer! + var mockRepository: MockBankTransferRepository! + + override func setUpWithError() throws { + try super.setUpWithError() + mockChargeContainer = MockChargeContainer() + mockRepository = MockBankTransferRepository() + serviceUnderTest = BankTransferViewModel( + chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: .example, + repository: mockRepository) + } + + // MARK: - Provisioning + + func testInitialStateIsLoading() { + XCTAssertEqual(serviceUnderTest.state, .loading()) + } + + func testProvisionVirtualAccountOnSuccessSetsStateToAwaitingPayment() async { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + + await serviceUnderTest.provisionVirtualAccount() + + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(expectedDetails)) + } + + func testProvisionVirtualAccountForwardsConfigFieldsToRepository() async { + let config = BankTransferConfig( + fulfilLateNotification: true, + transactionId: 999, + availableProviders: ["wema-bank"]) + serviceUnderTest = BankTransferViewModel( + chargeContainer: mockChargeContainer, + transactionDetails: .example, + config: config, + repository: mockRepository) + mockRepository.expectedBankTransferDetails = .example + + await serviceUnderTest.provisionVirtualAccount() + + XCTAssertEqual(mockRepository.payWithTransferSubmitted.fulfilLateNotification, true) + XCTAssertEqual(mockRepository.payWithTransferSubmitted.transactionId, 999) + XCTAssertNil(mockRepository.payWithTransferSubmitted.preferredProvider) + } + + func testProvisionVirtualAccountOnErrorSetsStateToError() async { + let expectedError = PaystackError.response(code: 500, message: "Boom") + mockRepository.expectedErrorResponse = expectedError + + await serviceUnderTest.provisionVirtualAccount() + + XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(error: expectedError))) + } + + func testProvisionVirtualAccountSetsLoadingStateBeforeAwaitingPayment() async { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + + XCTAssertEqual(serviceUnderTest.state, .loading()) + + await serviceUnderTest.provisionVirtualAccount() + + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(expectedDetails)) + } + + func testRetryProvisioningCallsRepositoryAgain() async { + mockRepository.expectedErrorResponse = PaystackError.technical + await serviceUnderTest.provisionVirtualAccount() + XCTAssertEqual(mockRepository.payWithTransferCallCount, 1) + + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedErrorResponse = nil + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.retryProvisioning() + + XCTAssertEqual(mockRepository.payWithTransferCallCount, 2) + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(expectedDetails)) + } + + // MARK: - User actions + + @MainActor func testUserTappedChangePaymentMethodCallsRestartFromChannelSelection() { + serviceUnderTest.userTappedChangePaymentMethod() + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + func testUserTappedIveSentTheMoneyTransitionsToConfirmingPaymentWaitingForCredit() async { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(expectedDetails, phase: .waitingForCredit)) + } + + func testUserTappedIveSentTheMoneyCallsCheckPendingChargeOnce() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertEqual(mockRepository.checkPendingChargeCallCount, 1) + XCTAssertEqual(mockRepository.pendingChargeAccessCode, + serviceUnderTest.transactionDetails.accessCode) + } + + func testUserTappedIveSentTheMoneyWithCheckPendingChargeSuccessRoutesToContainer() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .success) + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testUserTappedIveSentTheMoneyWithCheckPendingChargeFailureLeavesStateAsConfirmingPayment() async { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + + mockRepository.expectedErrorResponse = PaystackError.technical + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(expectedDetails, phase: .waitingForCredit)) + XCTAssertFalse(mockChargeContainer.transactionSuccessful) + } + + func testUserTappedIveSentTheMoneyFromLoadingStateDoesNothing() async { + XCTAssertEqual(serviceUnderTest.state, .loading()) + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertEqual(serviceUnderTest.state, .loading()) + XCTAssertEqual(mockRepository.checkPendingChargeCallCount, 0) + } + + func testUserTappedBackToAccountNumberReturnsToAwaitingPayment() async { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(expectedDetails, phase: .waitingForCredit)) + + await serviceUnderTest.userTappedBackToAccountNumber() + + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(expectedDetails)) + } + + func testUserTappedBackToAccountNumberResetsConfirmationElapsedSeconds() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + + serviceUnderTest.confirmationElapsedSeconds = 42 + await serviceUnderTest.userTappedBackToAccountNumber() + + XCTAssertEqual(serviceUnderTest.confirmationElapsedSeconds, 0) + } + + @MainActor func testUserTappedBackToAccountNumberFromLoadingStateDoesNothing() { + XCTAssertEqual(serviceUnderTest.state, .loading()) + + serviceUnderTest.userTappedBackToAccountNumber() + + XCTAssertEqual(serviceUnderTest.state, .loading()) + } + + // MARK: - Confirmation countdown + + func testInitialConfirmationElapsedSecondsIsZero() { + XCTAssertEqual(serviceUnderTest.confirmationElapsedSeconds, 0) + } + + func testConfirmationCountdownIsSkippedWhenWindowIsZero() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + + await serviceUnderTest.userTappedIveSentTheMoney() + + XCTAssertEqual(serviceUnderTest.confirmationElapsedSeconds, 0) + } + + func testConfirmationCountdownTicksElapsedSeconds() async throws { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 5 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + + await serviceUnderTest.userTappedIveSentTheMoney() + try await Task.sleep(nanoseconds: 1_300_000_000) + + XCTAssertGreaterThanOrEqual(serviceUnderTest.confirmationElapsedSeconds, 1) + XCTAssertLessThanOrEqual(serviceUnderTest.confirmationElapsedSeconds, 2) + + await serviceUnderTest.userTappedBackToAccountNumber() + } + + func testConfirmationCountdownExpiryTransitionsToTakingLongerThanExpected() async throws { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.provisionVirtualAccount() + + serviceUnderTest.confirmationWindowSeconds = 1 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + + try await Task.sleep(nanoseconds: 1_400_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .takingLongerThanExpected(expectedDetails)) + } + + func testConfirmationCountdownExpiryAfterStateAlreadyMovedDoesNotOverwrite() async throws { + let expectedDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = expectedDetails + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 1 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + + await serviceUnderTest.userTappedBackToAccountNumber() + try await Task.sleep(nanoseconds: 1_400_000_000) + + XCTAssertEqual(serviceUnderTest.state, + .awaitingPayment(expectedDetails)) + } + + func testUserTappedChangePaymentMethodAfterConfirmingResetsBackToChannelSelection() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + + await serviceUnderTest.userTappedChangePaymentMethod() + + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + func testDisplayTransactionErrorSetsStateToErrorWithGivenError() async { + let error = ChargeError(message: "Something broke") + await serviceUnderTest.displayTransactionError(error) + XCTAssertEqual(serviceUnderTest.state, .error(error)) + } + + @MainActor func testUserTappedBackToAccountNumberFromTakingLongerReturnsToAwaitingPayment() { + let expectedDetails: BankTransferDetails = .example + serviceUnderTest.state = .takingLongerThanExpected(expectedDetails) + + serviceUnderTest.userTappedBackToAccountNumber() + + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(expectedDetails)) + } + + // MARK: - Get help / keep waiting / close from delayed-confirmation + + @MainActor func testUserTappedGetHelpFromTakingLongerTransitionsToDelayedConfirmation() { + let expectedDetails: BankTransferDetails = .example + serviceUnderTest.state = .takingLongerThanExpected(expectedDetails) + + serviceUnderTest.userTappedGetHelp() + + XCTAssertEqual(serviceUnderTest.state, + .delayedConfirmation(expectedDetails)) + } + + @MainActor func testUserTappedGetHelpFromOtherStateDoesNothing() { + let expectedDetails: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(expectedDetails) + + serviceUnderTest.userTappedGetHelp() + + XCTAssertEqual(serviceUnderTest.state, + .awaitingPayment(expectedDetails)) + } + + @MainActor func testUserTappedKeepWaitingFromDelayedConfirmationReturnsToTakingLonger() { + let expectedDetails: BankTransferDetails = .example + serviceUnderTest.state = .delayedConfirmation(expectedDetails) + + serviceUnderTest.userTappedKeepWaiting() + + XCTAssertEqual(serviceUnderTest.state, + .takingLongerThanExpected(expectedDetails)) + } + + @MainActor func testUserTappedKeepWaitingFromOtherStateDoesNothing() { + let expectedDetails: BankTransferDetails = .example + serviceUnderTest.state = .takingLongerThanExpected(expectedDetails) + + serviceUnderTest.userTappedKeepWaiting() + + XCTAssertEqual(serviceUnderTest.state, + .takingLongerThanExpected(expectedDetails)) + } + + @MainActor func testUserTappedCloseFromDelayedConfirmationRestartsChannelSelection() { + serviceUnderTest.state = .delayedConfirmation(.example) + + serviceUnderTest.userTappedCloseFromDelayedConfirmation() + + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + // MARK: - processTransferUpdate — success / unknown + + func testProcessUpdateWithSuccessCallsContainerProcessSuccessfulTransaction() async { + await serviceUnderTest.processTransferUpdate( + .init(status: .success, message: nil, reference: nil, transactionId: nil)) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testProcessUpdateWithUnknownStatusDoesNotChangeState() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + + await serviceUnderTest.processTransferUpdate( + .init(status: .unknown("brand-new-status"), + message: nil, reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .waitingForCredit)) + } + + // MARK: - processTransferUpdate — auto-advance from awaitingPayment (PR 2) + + func testProcessUpdateWithCreditRequestPendingFromAwaitingPaymentAutoAdvances() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(details) + serviceUnderTest.confirmationWindowSeconds = 0 + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestPending, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .waitingForCredit)) + } + + func testProcessUpdateWithGenericPendingFromAwaitingPaymentAutoAdvances() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(details) + serviceUnderTest.confirmationWindowSeconds = 0 + + await serviceUnderTest.processTransferUpdate( + .init(status: .pending, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .waitingForCredit)) + } + + func testProcessUpdateWithCreditRequestReceivedFromAwaitingPaymentAutoAdvancesToTransferOnTheWay() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(details) + serviceUnderTest.confirmationWindowSeconds = 0 + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestReceived, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .transferOnTheWay)) + } + + func testProcessUpdateWithCreditRequestReceivedFromConfirmingAdvancesPhase() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + serviceUnderTest.confirmationWindowSeconds = 0 + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestReceived, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .transferOnTheWay)) + } + + // MARK: - processTransferUpdate — phase downgrade is prevented (Q6) + + func testProcessUpdateWithCreditRequestPendingDoesNotDowngradeFromTransferOnTheWay() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .transferOnTheWay) + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestPending, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .transferOnTheWay)) + } + + func testProcessUpdateWithGenericPendingDoesNotDowngradeFromTransferOnTheWay() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .transferOnTheWay) + + await serviceUnderTest.processTransferUpdate( + .init(status: .pending, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .transferOnTheWay)) + } + + // MARK: - processTransferUpdate — refundInitiated (was banner / error) + + func testProcessUpdateWithCreditRequestRejectedTransitionsToRefundInitiatedWithApiMessage() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestRejected, + message: "Bank declined transfer", + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .refundInitiated(details, message: "Bank declined transfer")) + } + + func testProcessUpdateWithCreditRequestRejectedFallsBackToCannedRefundCopyWhenNil() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + + await serviceUnderTest.processTransferUpdate( + .init(status: .creditRequestRejected, + message: nil, reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .refundInitiated(details, + message: BankTransferViewModel.refundInitiatedFallbackMessage)) + } + + func testProcessUpdateWithIncorrectAmountSentTransitionsToRefundInitiated() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + + await serviceUnderTest.processTransferUpdate( + .init(status: .incorrectAmountSent, + message: "Wrong amount, refund coming", + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .refundInitiated(details, message: "Wrong amount, refund coming")) + } + + func testProcessUpdateWithIncorrectAmountSentFromAwaitingPaymentAlsoRoutesToRefund() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(details) + + await serviceUnderTest.processTransferUpdate( + .init(status: .incorrectAmountSent, + message: nil, reference: nil, transactionId: nil)) + + // Auto-routing semantics: refund-initiated is terminal regardless + // of which screen the customer is on when the event arrives. + XCTAssertEqual(serviceUnderTest.state, + .refundInitiated(details, + message: BankTransferViewModel.refundInitiatedFallbackMessage)) + } + + // MARK: - processTransferUpdate — failed (new) + + func testProcessUpdateWithFailedSetsStateToErrorWithApiMessage() async { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + + await serviceUnderTest.processTransferUpdate( + .init(status: .failed, + message: "Transaction declined", + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Transaction declined"))) + } + + func testProcessUpdateWithFailedFallsBackToDefaultMessageWhenNil() async { + await serviceUnderTest.processTransferUpdate( + .init(status: .failed, message: nil, + reference: nil, transactionId: nil)) + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: BankTransferViewModel.failedFallbackMessage))) + } + + // MARK: - processTransferUpdate — requery (new) + + func testProcessUpdateWithRequeryTriggersSingleCheckPendingChargeCall() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + let baseline = mockRepository.checkPendingChargeCallCount + + await serviceUnderTest.processTransferUpdate( + .init(status: .requery, message: nil, + reference: nil, transactionId: nil)) + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(mockRepository.checkPendingChargeCallCount, baseline + 1) + XCTAssertEqual(mockRepository.pendingChargeAccessCode, + serviceUnderTest.transactionDetails.accessCode) + } + + func testProcessUpdateWithRequeryWithSuccessfulPollResolvesTransaction() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .success) + + await serviceUnderTest.processTransferUpdate( + .init(status: .requery, message: nil, + reference: nil, transactionId: nil)) + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + // MARK: - Pusher listen loop + + func testProvisioningStartsListenLoopOnReturnedChannel() async { + let details: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = details + + await serviceUnderTest.provisionVirtualAccount() + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertGreaterThanOrEqual(mockRepository.listenForTransferResponseCallCount, 1) + XCTAssertEqual(mockRepository.lastListenedChannel, details.pusherChannel) + } + + func testListenLoopProcessesMultipleNonTerminalEventsBeforeTerminating() async { + let details: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = details + mockRepository.expectedListenForTransferResponses = [ + .init(status: .creditRequestReceived, message: nil, + reference: nil, transactionId: nil), + .init(status: .creditRequestPending, message: nil, + reference: nil, transactionId: nil), + .init(status: .success, message: nil, + reference: nil, transactionId: nil) + ] + + let expectation = expectation(description: "container receives success") + mockChargeContainer.onProcessSuccessfulTransaction = { expectation.fulfill() } + + await serviceUnderTest.provisionVirtualAccount() + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertEqual(mockRepository.listenForTransferResponseCallCount, 3) + XCTAssertTrue(mockChargeContainer.transactionSuccessful) + } + + func testListenLoopExitsOnRejectedStatusWithRefundInitiated() async { + let details: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = details + mockRepository.expectedListenForTransferResponses = [ + .init(status: .creditRequestReceived, message: nil, + reference: nil, transactionId: nil), + .init(status: .creditRequestRejected, message: "Bank declined", + reference: nil, transactionId: nil) + ] + + await serviceUnderTest.provisionVirtualAccount() + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(mockRepository.listenForTransferResponseCallCount, 2) + XCTAssertEqual(serviceUnderTest.state, + .refundInitiated(details, message: "Bank declined")) + } + + func testListenLoopExitsOnFailedStatusToErrorState() async { + let details: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = details + mockRepository.expectedListenForTransferResponses = [ + .init(status: .failed, message: "Bank rejected", + reference: nil, transactionId: nil) + ] + + await serviceUnderTest.provisionVirtualAccount() + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(mockRepository.listenForTransferResponseCallCount, 1) + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(message: "Bank rejected"))) + } + + func testListenLoopExitsOnRepositoryErrorWithoutCrashing() async { + // After auto-advance the state is .confirmingPayment(.transferOnTheWay) + // — that's what the customer sees when the listen loop dies, not + // .awaitingPayment as in the old behaviour. + let details: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = details + mockRepository.expectedListenForTransferResponses = [ + .init(status: .creditRequestReceived, message: nil, + reference: nil, transactionId: nil) + ] + mockRepository.expectedListenForTransferError = PaystackError.technical + + await serviceUnderTest.provisionVirtualAccount() + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(mockRepository.listenForTransferResponseCallCount, 2) + XCTAssertEqual(serviceUnderTest.state, + .confirmingPayment(details, phase: .transferOnTheWay)) + } + + // MARK: - refundInitiated → "Choose another payment method" + + @MainActor func testUserTappedChooseAnotherPaymentMethodFromRefundRestartsChannelSelection() { + serviceUnderTest.state = .refundInitiated(.example, message: "Refund initiated") + + serviceUnderTest.userTappedChooseAnotherPaymentMethodFromRefund() + + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) + } + + // MARK: - currentBankSlug + + func testAvailableBankSlugsForwardsFromConfig() { + XCTAssertEqual(serviceUnderTest.availableBankSlugs, + ["wema-bank", "titan-paystack"]) + } + + func testCurrentBankSlugIsNilWhenStateIsLoading() { + XCTAssertEqual(serviceUnderTest.state, .loading()) + XCTAssertNil(serviceUnderTest.currentBankSlug) + } + + func testCurrentBankSlugReturnsSlugFromAwaitingPayment() { + let details: BankTransferDetails = .example + serviceUnderTest.state = .awaitingPayment(details) + XCTAssertEqual(serviceUnderTest.currentBankSlug, details.bankSlug) + } + + func testCurrentBankSlugReturnsSlugFromConfirmingPayment() { + let details: BankTransferDetails = .example + serviceUnderTest.state = .confirmingPayment(details, phase: .waitingForCredit) + XCTAssertEqual(serviceUnderTest.currentBankSlug, details.bankSlug) + } + + func testCurrentBankSlugReturnsSlugFromTakingLongerAndDelayedConfirmation() { + let details: BankTransferDetails = .example + serviceUnderTest.state = .takingLongerThanExpected(details) + XCTAssertEqual(serviceUnderTest.currentBankSlug, details.bankSlug) + + serviceUnderTest.state = .delayedConfirmation(details) + XCTAssertEqual(serviceUnderTest.currentBankSlug, details.bankSlug) + } + + func testCurrentBankSlugReturnsSlugFromRefundInitiated() { + let details: BankTransferDetails = .example + serviceUnderTest.state = .refundInitiated(details, message: "x") + XCTAssertEqual(serviceUnderTest.currentBankSlug, details.bankSlug) + } + + // MARK: - User selected bank + + func testUserSelectedBankCallsRepositoryWithPreferredProvider() async { + let newDetails = BankTransferDetails( + accountName: "WEMA TEST", + accountNumber: "1234567890", + bankName: "Wema Bank", + bankSlug: "wema-bank", + transactionReference: "T_new", + pusherChannel: "PWT_new", + accountExpiresAt: Date().addingTimeInterval(30 * 60), + transactionId: "9999") + mockRepository.expectedBankTransferDetails = newDetails + + await serviceUnderTest.userSelectedBank(slug: "wema-bank") + + XCTAssertEqual(mockRepository.payWithTransferSubmitted.preferredProvider, + "wema-bank") + XCTAssertEqual(mockRepository.payWithTransferSubmitted.fulfilLateNotification, + false) + XCTAssertEqual(mockRepository.payWithTransferSubmitted.transactionId, 1234) + } + + func testUserSelectedBankTransitionsToAwaitingPaymentWithNewDetails() async { + let newDetails = BankTransferDetails( + accountName: "WEMA TEST", + accountNumber: "1234567890", + bankName: "Wema Bank", + bankSlug: "wema-bank", + transactionReference: "T_new", + pusherChannel: "PWT_new", + accountExpiresAt: Date().addingTimeInterval(30 * 60), + transactionId: "9999") + mockRepository.expectedBankTransferDetails = newDetails + + await serviceUnderTest.userSelectedBank(slug: "wema-bank") + + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(newDetails)) + } + + func testUserSelectedBankStartsFreshPusherSubscriptionOnNewChannel() async { + let initialDetails: BankTransferDetails = .example + mockRepository.expectedBankTransferDetails = initialDetails + await serviceUnderTest.provisionVirtualAccount() + try? await Task.sleep(nanoseconds: 100_000_000) + let callsAfterInitial = mockRepository.listenForTransferResponseCallCount + + let newChannel = "PWT_brand_new" + let newDetails = BankTransferDetails( + accountName: "X", accountNumber: "Y", bankName: "Wema Bank", + bankSlug: "wema-bank", transactionReference: "Z", + pusherChannel: newChannel, + accountExpiresAt: Date().addingTimeInterval(30 * 60), + transactionId: "9999") + mockRepository.expectedBankTransferDetails = newDetails + + await serviceUnderTest.userSelectedBank(slug: "wema-bank") + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertGreaterThan(mockRepository.listenForTransferResponseCallCount, + callsAfterInitial) + XCTAssertEqual(mockRepository.lastListenedChannel, newChannel) + } + + func testUserSelectedBankCancelsConfirmationTimer() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + serviceUnderTest.confirmationWindowSeconds = 0 + mockRepository.expectedChargeCardTransaction = ChargeCardTransaction(status: .pending) + await serviceUnderTest.userTappedIveSentTheMoney() + serviceUnderTest.confirmationElapsedSeconds = 17 + + let newDetails = BankTransferDetails( + accountName: "X", accountNumber: "Y", bankName: "Wema Bank", + bankSlug: "wema-bank", transactionReference: "Z", + pusherChannel: "PWT_new", + accountExpiresAt: Date().addingTimeInterval(30 * 60), + transactionId: "9999") + mockRepository.expectedBankTransferDetails = newDetails + + await serviceUnderTest.userSelectedBank(slug: "wema-bank") + + XCTAssertEqual(serviceUnderTest.confirmationElapsedSeconds, 0) + XCTAssertEqual(serviceUnderTest.state, .awaitingPayment(newDetails)) + } + + func testUserSelectedBankOnErrorSetsStateToError() async { + mockRepository.expectedBankTransferDetails = .example + await serviceUnderTest.provisionVirtualAccount() + + let expectedError = PaystackError.response(code: 500, message: "Boom") + mockRepository.expectedBankTransferDetails = nil + mockRepository.expectedErrorResponse = expectedError + + await serviceUnderTest.userSelectedBank(slug: "wema-bank") + + XCTAssertEqual(serviceUnderTest.state, + .error(ChargeError(error: expectedError))) + } +} + +private extension BankTransferConfig { + static let example = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: ["wema-bank", "titan-paystack"]) +} diff --git a/Tests/PaystackSDKTests/UI/Charge/BankTransferRepository/BankTransferRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/BankTransferRepository/BankTransferRepositoryImplementationTests.swift new file mode 100644 index 0000000..4108c42 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/BankTransferRepository/BankTransferRepositoryImplementationTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class BankTransferRepositoryImplementationTests: PSTestCase { + + let apiKey = "testsk_Example" + var serviceUnderTest: BankTransferRepositoryImplementation! + var paystack: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + paystack = try PaystackBuilder.newInstance.setKey(apiKey).build() + PaystackContainer.instance.store(paystack) + serviceUnderTest = BankTransferRepositoryImplementation() + } + + func testPayWithTransferSubmitsRequestUsingPaystackObjectAndMapsCorrectlyToModel() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + let result = try await serviceUnderTest.payWithTransfer( + fulfilLateNotification: true, + transactionId: 6215047322, + preferredProvider: nil) + + XCTAssertEqual(result, .jsonExample) + } + + func testPayWithTransferMapsBankNameAndSlugFromResponse() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + let result = try await serviceUnderTest.payWithTransfer( + fulfilLateNotification: false, + transactionId: 6215047322, + preferredProvider: nil) + + XCTAssertEqual(result.bankName, "Paystack-Titan") + XCTAssertEqual(result.bankSlug, "titan-paystack") + XCTAssertEqual(result.accountNumber, "9985488398") + XCTAssertEqual(result.accountName, "PAYSTACK CHECKOUT") + XCTAssertEqual(result.pusherChannel, "PWT6215047322") + XCTAssertEqual(result.transactionId, "6215047322") + XCTAssertEqual(result.transactionReference, "T6215047322I100043S0g703") + } + + func testPayWithTransferForwardsPreferredProviderWhenSupplied() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/checkout/pay_with_transfer") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "PayWithTransferResponse") + + _ = try await serviceUnderTest.payWithTransfer( + fulfilLateNotification: true, + transactionId: 6215047322, + preferredProvider: "wema-bank") + } + + func testCheckPendingChargeSubmitsRequestUsingPaystackObjectAndMapsCorrectlyToModel() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/charge/access_code_test") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ChargeAuthenticationResponse") + + let result = try await serviceUnderTest.checkPendingCharge(with: "access_code_test") + XCTAssertEqual(result.status, .success) + } + + func testListenForTransferResponseSubscribesToProvidedChannelAndMapsSuccess() async throws { + let channel = "PWT6215047322" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + let result = try await serviceUnderTest.listenForTransferResponse(onChannel: channel) + + XCTAssertEqual(result.status, .success) + XCTAssertEqual(result.message, "Payment Successful") + XCTAssertEqual(result.transactionId, "3818017015") + XCTAssertEqual(result.reference, "T3818017015I615243Sujjxh") + } + + func testListenForTransferResponseMapsCreditRequestReceivedEvent() async throws { + let channel = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditReceived") + + let result = try await serviceUnderTest.listenForTransferResponse(onChannel: channel) + + XCTAssertEqual(result.status, .creditRequestReceived) + XCTAssertEqual(result.message, "credit request received") + XCTAssertEqual(result.transactionId, "3818017015") + } + + func testListenForTransferResponseMapsCreditRequestPendingEvent() async throws { + let channel = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditPending") + + let result = try await serviceUnderTest.listenForTransferResponse(onChannel: channel) + + XCTAssertEqual(result.status, .creditRequestPending) + } + + func testListenForTransferResponseMapsCreditRequestRejectedEvent() async throws { + let channel = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherCreditRejected") + + let result = try await serviceUnderTest.listenForTransferResponse(onChannel: channel) + + XCTAssertEqual(result.status, .creditRequestRejected) + XCTAssertEqual(result.message, "Transfer was rejected") + } + + /// Under the new taxonomy a bare `status: "failed"` event maps to + /// `.failed` regardless of the message field — the legacy "failed + + /// incorrect amount in message → incorrectAmount" fallback is gone. + /// The explicit `incorrect-amount-sent` status string is what now + /// signals the refund-initiated wrong-amount case. + func testListenForTransferResponseMapsBareFailedShapeToFailed() async throws { + let channel = "PWT3818017015" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherIncorrectAmount") + + let result = try await serviceUnderTest.listenForTransferResponse(onChannel: channel) + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "incorrect amount sent") + } +} + +private extension BankTransferDetails { + static var jsonExample: BankTransferDetails { + BankTransferDetails( + accountName: "PAYSTACK CHECKOUT", + accountNumber: "9985488398", + bankName: "Paystack-Titan", + bankSlug: "titan-paystack", + transactionReference: "T6215047322I100043S0g703", + pusherChannel: "PWT6215047322", + accountExpiresAt: DateFormatter.paystackFormatter + .date(from: "2026-06-02T15:18:37.053Z")!, + transactionId: "6215047322") + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift index 99e1f78..2fb35c1 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeRepositoryImplementationTests.swift @@ -24,6 +24,14 @@ final class ChargeRepositoryImplementationTests: PSTestCase { let result = try await serviceUnderTest.verifyAccessCode("access_code_test") let phoneNumberRegex = "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$" + let expectedChannelOptions = PaystackUI.ChannelOptions( + mobileMoney: [ + .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"]) + let expectedMerchantSettings = MerchantChannelSettings( + bankTransfer: BankTransferMerchantSettings(fulfilLateNotification: true)) let expectedResult = VerifyAccessCode(amount: 10000, currency: "NGN", accessCode: "Access_Code_Test", @@ -32,12 +40,37 @@ final class ChargeRepositoryImplementationTests: PSTestCase { merchantName: "Test Merchant", publicEncryptionKey: "test_encryption_key", reference: "203520101", - channelOptions: PaystackUI.ChannelOptions(mobileMoney: [ - .init(key: "MPESA", value: "M-PESA", isNew: true, phoneNumberRegex: phoneNumberRegex), - .init(key: "MPESA_OFF", value: "M-PESA", isNew: false, phoneNumberRegex: phoneNumberRegex) - ])) + channelOptions: expectedChannelOptions, + merchantChannelSettings: expectedMerchantSettings) XCTAssertEqual(result, expectedResult) } + func testVerifyAccessCodeDecodesBankTransferChannelOptionSlugs() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/verify_code/access_code_test") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "VerifyAccessCode") + + let result = try await serviceUnderTest.verifyAccessCode("access_code_test") + + XCTAssertEqual(result.channelOptions?.bankTransfer, + ["wema-bank", "titan-paystack", "paystack-mfb"]) + } + + func testVerifyAccessCodeDecodesMerchantBankTransferFulfilLateNotification() async throws { + mockServiceExecutor + .expectURL("https://api.paystack.co/transaction/verify_code/access_code_test") + .expectMethod(.get) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "VerifyAccessCode") + + let result = try await serviceUnderTest.verifyAccessCode("access_code_test") + + XCTAssertEqual( + result.merchantChannelSettings?.bankTransfer?.fulfilLateNotification, + true) + } + } diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index ebb6b24..55bbe24 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -7,9 +7,6 @@ final class ChargeViewModelTests: PSTestCase { var serviceUnderTest: ChargeViewModel! var mockRepo: MockChargeRepository! - /// Snapshot the production allowlist on entry so individual tests can - /// mutate `supportedMobileMoneyProviders` freely and we restore the - /// original value in `tearDown`. Avoids order-dependent test leakage. private static let productionAllowlist = ChargeViewModel.supportedMobileMoneyProviders override func setUpWithError() throws { @@ -62,8 +59,6 @@ final class ChargeViewModelTests: PSTestCase { } - // MARK: - Resolver and auto-route - func testAutoRoutesToMobileMoneyWhenCardUnsupportedAndExactlyOneAllowlistedProvider() async { ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] let response = VerifyAccessCode.with(channels: [.mobileMoney], @@ -78,7 +73,7 @@ final class ChargeViewModelTests: PSTestCase { } func testShowsChannelSelectionWhenMultipleMobileMoneyProvidersAndNoCard() async { - ChargeViewModel.supportedMobileMoneyProviders = nil // accept everything + ChargeViewModel.supportedMobileMoneyProviders = nil let response = VerifyAccessCode.with(channels: [.mobileMoney], mobileMoney: [.mtnFixture, .vodafoneFixture]) mockRepo.expectedVerifyAccessCode = response @@ -147,8 +142,6 @@ final class ChargeViewModelTests: PSTestCase { } func testTransactionDetailsIsSetAfterResolvingMobileMoneyAutoRoute() async { - // Regression guard: pre-PR-2 the MM branch never set `transactionDetails`, - // so downstream UI checks (inTestMode, chargeCancelled) saw nil. ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] let response = VerifyAccessCode.with(channels: [.mobileMoney], mobileMoney: [.mpesaFixture]) @@ -159,6 +152,147 @@ final class ChargeViewModelTests: PSTestCase { XCTAssertEqual(serviceUnderTest.transactionDetails, response) } + func testAutoRoutesToBankTransferWhenItIsTheOnlyChannel() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + bankTransferProviders: ["wema-bank", "titan-paystack"], + fulfilLateNotification: true, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = BankTransferConfig( + fulfilLateNotification: true, + transactionId: 1234, + availableProviders: ["wema-bank", "titan-paystack"]) + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .bankTransfer(transactionInformation: response, + config: expectedConfig))) + } + + func testShowsChannelSelectionWhenBankTransferAndCardBothSupported() async { + let response = VerifyAccessCode.with( + channels: [.card, .bankTransfer], + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: ["wema-bank"]) + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .bankTransfer(expectedConfig)])) + } + + func testShowsChannelSelectionWhenBankTransferAndMobileMoneyAndCardSupported() async { + ChargeViewModel.supportedMobileMoneyProviders = ["MPESA"] + let response = VerifyAccessCode.with( + channels: [.card, .mobileMoney, .bankTransfer], + mobileMoney: [.mpesaFixture], + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: ["wema-bank"]) + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .mobileMoney(.mpesaFixture), + .bankTransfer(expectedConfig)])) + } + + func testBankTransferConfigDefaultsFulfilLateNotificationToFalseWhenAbsent() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + bankTransferProviders: ["titan-paystack"], + fulfilLateNotification: nil, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + let expectedConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: ["titan-paystack"]) + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .bankTransfer(transactionInformation: response, + config: expectedConfig))) + } + + func testBankTransferDoesNotAppearWhenChannelArrayOmitsIt() async { + let response = VerifyAccessCode.with( + channels: [.card], + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: true, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + XCTAssertEqual(serviceUnderTest.transactionState, + .payment(type: .card(transactionInformation: response))) + } + + func testBankTransferDoesNotAppearWhenTransactionIdMissing() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: true, + 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], + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + serviceUnderTest.transactionState = .payment( + type: .card(transactionInformation: response)) + + serviceUnderTest.restartFromChannelSelection() + + let expectedConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: ["wema-bank"]) + XCTAssertEqual(serviceUnderTest.transactionState, + .channelSelection(transactionInformation: response, + supportedChannels: [.card, + .bankTransfer(expectedConfig)])) + } + + func testRestartFromChannelSelectionWithoutCachedDetailsSetsErrorState() { + serviceUnderTest.transactionDetails = nil + serviceUnderTest.restartFromChannelSelection() + XCTAssertEqual(serviceUnderTest.transactionState, .error(.generic)) + } + func testViewShouldBeCenteredForSpecifiedStates() { serviceUnderTest.transactionState = .loading() XCTAssertFalse(serviceUnderTest.centerView) @@ -233,8 +367,6 @@ extension ChargeCompletionDetails: Equatable { } } -// MARK: - Test fixtures - private extension MobileMoneyChannel { static let mpesaFixture = MobileMoneyChannel(key: "MPESA", value: "M-PESA", @@ -251,18 +383,30 @@ private extension MobileMoneyChannel { } private extension VerifyAccessCode { - /// Compact helper for building `VerifyAccessCode` fixtures for resolver tests. - /// Most fields are irrelevant to channel resolution and get sensible defaults. static func with(channels: [PaystackCore.Channel], - mobileMoney: [MobileMoneyChannel]? = nil) -> Self { - VerifyAccessCode(amount: 10000, - currency: "USD", - accessCode: "test_access", - paymentChannels: channels, - domain: .test, - merchantName: "Test Merchant", - publicEncryptionKey: "test_encryption_key", - reference: "test_reference", - channelOptions: mobileMoney.map { PaystackUI.ChannelOptions(mobileMoney: $0) }) + mobileMoney: [MobileMoneyChannel]? = nil, + bankTransferProviders: [String]? = nil, + fulfilLateNotification: Bool? = nil, + transactionId: Int? = 1234) -> Self { + let channelOptions: PaystackUI.ChannelOptions? = { + if mobileMoney == nil && bankTransferProviders == nil { return nil } + return PaystackUI.ChannelOptions(mobileMoney: mobileMoney, + bankTransfer: bankTransferProviders) + }() + let settings: MerchantChannelSettings? = fulfilLateNotification.map { + MerchantChannelSettings( + bankTransfer: BankTransferMerchantSettings(fulfilLateNotification: $0)) + } + return VerifyAccessCode(amount: 10000, + currency: "USD", + accessCode: "test_access", + paymentChannels: channels, + domain: .test, + merchantName: "Test Merchant", + publicEncryptionKey: "test_encryption_key", + reference: "test_reference", + transactionId: transactionId, + channelOptions: channelOptions, + merchantChannelSettings: settings) } } diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockBankTransferRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockBankTransferRepository.swift new file mode 100644 index 0000000..8d774d3 --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockBankTransferRepository.swift @@ -0,0 +1,58 @@ +import Foundation +@testable import PaystackUI + +class MockBankTransferRepository: BankTransferRepository { + + var expectedBankTransferDetails: BankTransferDetails? + var expectedChargeCardTransaction: ChargeCardTransaction? + var expectedErrorResponse: Error? + + var expectedListenForTransferResponses: [BankTransferTransactionUpdate] = [] + var expectedListenForTransferError: Error? + + var payWithTransferSubmitted: (fulfilLateNotification: Bool, + transactionId: Int, + preferredProvider: String?) = (false, 0, nil) + private(set) var payWithTransferCallCount = 0 + + private(set) var checkPendingChargeCallCount = 0 + private(set) var pendingChargeAccessCode: String? + + private(set) var listenForTransferResponseCallCount = 0 + private(set) var lastListenedChannel: String? + + func payWithTransfer(fulfilLateNotification: Bool, + transactionId: Int, + preferredProvider: String?) async throws -> BankTransferDetails { + payWithTransferCallCount += 1 + payWithTransferSubmitted = (fulfilLateNotification, transactionId, preferredProvider) + guard let details = expectedBankTransferDetails else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return details + } + + func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction { + checkPendingChargeCallCount += 1 + pendingChargeAccessCode = accessCode + guard let response = expectedChargeCardTransaction else { + throw expectedErrorResponse ?? MockError.stubNotProvided + } + return response + } + + func listenForTransferResponse(onChannel channelName: String) + async throws -> BankTransferTransactionUpdate { + listenForTransferResponseCallCount += 1 + lastListenedChannel = channelName + + if !expectedListenForTransferResponses.isEmpty { + return expectedListenForTransferResponses.removeFirst() + } + if let error = expectedListenForTransferError { + expectedListenForTransferError = nil + throw error + } + throw expectedErrorResponse ?? MockError.stubNotProvided + } +} diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeContainer.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeContainer.swift index d5adcec..f2b0e81 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeContainer.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockChargeContainer.swift @@ -3,9 +3,17 @@ import Foundation class MockChargeContainer: ChargeContainer { var transactionSuccessful = false + var channelSelectionRestarted = false + + var onProcessSuccessfulTransaction: (() -> Void)? func processSuccessfulTransaction(details: VerifyAccessCode) { transactionSuccessful = true + onProcessSuccessfulTransaction?() + } + + func restartFromChannelSelection() { + channelSelectionRestarted = true } }