Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Sources/PaystackUI/Charge/ChargeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ struct ChargeView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 140)
}
.task(viewModel.verifyAccessCodeAndProceedWithCard)
.task(viewModel.verifyAccessCodeAndProceed)
.modalCancelButton(showConfirmation: viewModel.displayCloseButtonConfirmation,
onCancelled: chargeCancelled)
}
Expand All @@ -59,8 +59,10 @@ struct ChargeView: View {
case .card(let transactionInformation):
ChargeCardView(transactionDetails: transactionInformation,
chargeContainer: viewModel)
case .mobileMoney(transactionInformation: let transactionInformation):
MPesaChargeView(chargeCardContainer: viewModel, transactionDetails: transactionInformation)
case .mobileMoney(let transactionInformation, let provider):
MPesaChargeView(chargeCardContainer: viewModel,
transactionDetails: transactionInformation,
provider: provider)
}
}

Expand Down
101 changes: 62 additions & 39 deletions Sources/PaystackUI/Charge/ChargeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,37 +19,22 @@ class ChargeViewModel: ObservableObject {
}

@MainActor
func verifyAccessCodeAndProceedWithCard() async {
var supportedChannels: [SupportedChannels] = []
func verifyAccessCodeAndProceed() async {
do {
transactionState = .loading()
let accessCodeResponse = try await repository.verifyAccessCode(accessCode)
guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card || $0 == .mobileMoney }) else {
let message = "Card/MPesa payments are not supported. " +
let response = try await repository.verifyAccessCode(accessCode)
let supported = resolveSupportedChannels(from: response)

guard !supported.isEmpty else {
let message = "No supported payment methods. " +
"Please reach out to your merchant for further information"
let cause = "There are currently no payment channels on " +
"your integration that are supported by the SDK"
let cause = "No payment channels on this integration " +
"are supported by the SDK"
throw ChargeError(displayMessage: message, causeMessage: cause)
}

let mobileMoneyChannel = accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false

accessCodeResponse.paymentChannels.forEach {
if $0 == .card {
supportedChannels.append(.CARD)
}
if $0 == .mobileMoney && accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false {
supportedChannels.append(.MPESA)
}
}

if mobileMoneyChannel {
transactionState = .channelSelection(
transactionInformation: accessCodeResponse, supportedChannels: supportedChannels)
} else {
self.transactionDetails = accessCodeResponse
transactionState = .payment(type: .card(transactionInformation: accessCodeResponse))
}
transactionDetails = response
transactionState = nextState(for: supported, response: response)
} catch {
let error = ChargeError(error: error)
Logger.error("Verify access code failed with error: %@",
Expand All @@ -59,24 +44,62 @@ class ChargeViewModel: ObservableObject {
}
}

}
private func resolveSupportedChannels(from response: VerifyAccessCode) -> [SupportedChannel] {
var result: [SupportedChannel] = []

if response.paymentChannels.contains(.card) {
result.append(.card)
}

if response.paymentChannels.contains(.mobileMoney),
let providers = response.channelOptions?.mobileMoney, !providers.isEmpty {
let allowed = filtered(providers)
result.append(contentsOf: allowed.map { .mobileMoney($0) })
}

return result
}

enum SupportedChannels: String, CaseIterable {
case CARD = "CARD"
case MPESA = "MPESA"
case unsupportedChannel

var image: Image {
switch self {
case .CARD:
return Image("cardLogo", bundle: .current)
case .MPESA:
return Image("kenyaSHLogo", bundle: .current)
case .unsupportedChannel:
return Image(systemName: "exclamationmark.triangle.fill")
private func filtered(_ providers: [MobileMoneyChannel]) -> [MobileMoneyChannel] {
guard let allowlist = Self.supportedMobileMoneyProviders else {
return providers
}
return providers.filter { allowlist.contains($0.key.uppercased()) }
}

private func nextState(for channels: [SupportedChannel],
response: VerifyAccessCode) -> ChargeState {
if channels.count == 1, case .card = channels[0] {
return .payment(type: .card(transactionInformation: response))
}

if !channels.contains(.card),
channels.count == 1,
case .mobileMoney(let provider) = channels[0] {
return .payment(type: .mobileMoney(transactionInformation: response,
provider: provider))
}

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<String>? = [
"MPESA"
]
}

// MARK: - Charge Container
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
var chargeCardContainer: ChargeContainer
var repository: ChargeMobileMoneyRepository
var transactionDetails: VerifyAccessCode
let provider: MobileMoneyChannel
@Published
var phoneNumber: String = ""

Expand All @@ -21,10 +22,12 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {

init(chargeCardContainer: ChargeContainer,
transactionDetails: VerifyAccessCode,
provider: MobileMoneyChannel,
repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) {
self.chargeCardContainer = chargeCardContainer
self.repository = repository
self.transactionDetails = transactionDetails
self.provider = provider
}

var isValid: Bool {
Expand All @@ -37,7 +40,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
let authenticationResult = try await repository.chargeMobileMoney(
phone: phoneNumber.formattedKenyanPhoneNumber,
transactionId: "\(transactionDetails.transactionId ?? 0)",
provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "")
provider: provider.key)
transactionState = .processTransaction(transaction: authenticationResult)
} catch {
displayTransactionError(ChargeError(error: error))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ struct ChannelSelectionView: View {
var visibilityContainer: ViewVisibilityContainer
@StateObject
var viewModel: ChannelSelectionViewModel
let supportedChannels: [SupportedChannel]
let columns = [GridItem(.flexible()), GridItem(.flexible())]
var items: [PaymentChannel] = []

init(state: Binding<ChargeState>,
supportedChannels: [SupportedChannels],
supportedChannels: [SupportedChannel],
information: VerifyAccessCode) {
self._viewModel = StateObject(wrappedValue: ChannelSelectionViewModel(state: state, information: information))
items = supportedChannels.map {
PaymentChannel(channel: $0)
}
self.supportedChannels = supportedChannels
}

var body: some View {
Expand All @@ -35,13 +34,13 @@ struct ChannelSelectionView: View {
.multilineTextAlignment(.center)
GeometryReader { geo in
LazyVGrid(columns: columns) {
ForEach(items) { value in
ChannelView(channelTitle: value.title, image: value.image)
ForEach(supportedChannels) { channel in
ChannelView(channelTitle: channel.displayTitle, image: channel.image)
.padding(.singlePadding)
.onTapGesture {
viewModel.chooseChannel(channel: value.channel)
viewModel.choose(channel)
}
.frame(width: (geo.size.width / CGFloat(items.count)).rounded())
.frame(width: (geo.size.width / CGFloat(supportedChannels.count)).rounded())
}
}
}
Expand All @@ -59,31 +58,14 @@ class ChannelSelectionViewModel: ObservableObject {
self.information = information
}

func chooseChannel(channel: SupportedChannels) {
let message = "Card/MPesa payments are not supported. " +
"Please reach out to your merchant for further information"
let cause = "There are currently no payment channels on " +
"your integration that are supported by the SDK"
func choose(_ channel: SupportedChannel) {
switch channel {
case .CARD:
case .card:
state = .payment(type: .card(transactionInformation: self.information))
case .MPESA:
state = .payment(type: .mobileMoney(transactionInformation: self.information))
case .unsupportedChannel:
state = .error(ChargeError(displayMessage: message, causeMessage: cause))
case .mobileMoney(let provider):
state = .payment(type: .mobileMoney(transactionInformation: self.information,
provider: provider))
}

}
}

struct PaymentChannel: Identifiable {
var id: String = UUID().uuidString
let channel: SupportedChannels
var title: String {
channel.rawValue
}
var image: Image {
channel.image
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ struct MPesaChargeView: View {

init(
chargeCardContainer: ChargeContainer,
transactionDetails: VerifyAccessCode) {
transactionDetails: VerifyAccessCode,
provider: MobileMoneyChannel) {
self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel(
chargeCardContainer: chargeCardContainer, transactionDetails: transactionDetails))
chargeCardContainer: chargeCardContainer,
transactionDetails: transactionDetails,
provider: provider))
}

var body: some View {
Expand Down
3 changes: 2 additions & 1 deletion Sources/PaystackUI/Charge/Models/ChargePaymentType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ 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)
case mobileMoney(transactionInformation: VerifyAccessCode,
provider: MobileMoneyChannel)
}
2 changes: 1 addition & 1 deletion Sources/PaystackUI/Charge/Models/ChargeState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PaystackCore
enum ChargeState {
case loading(message: String? = nil)
case payment(type: ChargePaymentType)
case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannels])
case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannel])
case error(ChargeError)
case success(amount: AmountCurrency, merchant: String,
details: ChargeCompletionDetails)
Expand Down
50 changes: 50 additions & 0 deletions Sources/PaystackUI/Charge/Models/SupportedChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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)

var id: String {
switch self {
case .card:
return "card"
case .mobileMoney(let channel):
return "mobile_money.\(channel.key)"
}
}

var displayTitle: String {
switch self {
case .card:
return "Card"
case .mobileMoney(let channel):
return channel.value
}
}

var image: Image {
switch self {
case .card:
return Image("cardLogo", bundle: .current)
case .mobileMoney(let channel):
return Self.image(forMobileMoneyKey: channel.key)
}
}

/// 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() {

Check warning on line 43 in Sources/PaystackUI/Charge/Models/SupportedChannel.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this "switch" statement with "if" statement to increase readability.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AZ5AWBINwvbYEhK6mw6Q&open=AZ5AWBINwvbYEhK6mw6Q&pullRequest=121
case "MPESA":
return Image("kenyaSHLogo", bundle: .current)
default:
return Image(systemName: "creditcard")
}
}
}
Loading
Loading