Skip to content

Commit f15aa92

Browse files
Refactor mobile money channel resolution and inject provider per tran… (#121)
Refactor mobile money channel resolution and inject provider per transaction Replaces the implicit MPesa-only assumption with an explicit SupportedChannel model and a configurable provider allowlist on ChargeViewModel. The selected MobileMoneyChannel is now threaded through ChargePaymentType, MPesaChargeView, and MPesaChrageViewModel so the charge request uses the chosen provider's key instead of always picking the first channelOption. - Adds Sources/PaystackUI/Charge/Models/SupportedChannel.swift with id, title, and image per channel (card + per-provider mobile money). - Renames verifyAccessCodeAndProceedWithCard -> verifyAccessCodeAndProceed and splits it into resolveSupportedChannels / nextState helpers; auto-routes single-channel cases and falls back to channel selection otherwise. - Drops the SupportedChannels enum / PaymentChannel wrapper in ChannelSelectionView in favour of iterating SupportedChannel directly. - Adds resolver coverage in ChargeViewModelTests (allowlist filtering, auto-route, channel selection, transactionDetails regression guard) and updates MPesaChrageViewModelTests to verify the injected provider key is forwarded to the repository.
1 parent bbbc34a commit f15aa92

10 files changed

Lines changed: 301 additions & 95 deletions

File tree

Sources/PaystackUI/Charge/ChargeView.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ struct ChargeView: View {
4848
.aspectRatio(contentMode: .fit)
4949
.frame(width: 140)
5050
}
51-
.task(viewModel.verifyAccessCodeAndProceedWithCard)
51+
.task(viewModel.verifyAccessCodeAndProceed)
5252
.modalCancelButton(showConfirmation: viewModel.displayCloseButtonConfirmation,
5353
onCancelled: chargeCancelled)
5454
}
@@ -59,8 +59,10 @@ struct ChargeView: View {
5959
case .card(let transactionInformation):
6060
ChargeCardView(transactionDetails: transactionInformation,
6161
chargeContainer: viewModel)
62-
case .mobileMoney(transactionInformation: let transactionInformation):
63-
MPesaChargeView(chargeCardContainer: viewModel, transactionDetails: transactionInformation)
62+
case .mobileMoney(let transactionInformation, let provider):
63+
MPesaChargeView(chargeCardContainer: viewModel,
64+
transactionDetails: transactionInformation,
65+
provider: provider)
6466
}
6567
}
6668

Sources/PaystackUI/Charge/ChargeViewModel.swift

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,22 @@ class ChargeViewModel: ObservableObject {
1919
}
2020

2121
@MainActor
22-
func verifyAccessCodeAndProceedWithCard() async {
23-
var supportedChannels: [SupportedChannels] = []
22+
func verifyAccessCodeAndProceed() async {
2423
do {
2524
transactionState = .loading()
26-
let accessCodeResponse = try await repository.verifyAccessCode(accessCode)
27-
guard accessCodeResponse.paymentChannels.contains(where: { $0 == .card || $0 == .mobileMoney }) else {
28-
let message = "Card/MPesa payments are not supported. " +
25+
let response = try await repository.verifyAccessCode(accessCode)
26+
let supported = resolveSupportedChannels(from: response)
27+
28+
guard !supported.isEmpty else {
29+
let message = "No supported payment methods. " +
2930
"Please reach out to your merchant for further information"
30-
let cause = "There are currently no payment channels on " +
31-
"your integration that are supported by the SDK"
31+
let cause = "No payment channels on this integration " +
32+
"are supported by the SDK"
3233
throw ChargeError(displayMessage: message, causeMessage: cause)
3334
}
3435

35-
let mobileMoneyChannel = accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false
36-
37-
accessCodeResponse.paymentChannels.forEach {
38-
if $0 == .card {
39-
supportedChannels.append(.CARD)
40-
}
41-
if $0 == .mobileMoney && accessCodeResponse.channelOptions?.mobileMoney?.contains(where: { $0.key == SupportedChannels.MPESA.rawValue }) ?? false {
42-
supportedChannels.append(.MPESA)
43-
}
44-
}
45-
46-
if mobileMoneyChannel {
47-
transactionState = .channelSelection(
48-
transactionInformation: accessCodeResponse, supportedChannels: supportedChannels)
49-
} else {
50-
self.transactionDetails = accessCodeResponse
51-
transactionState = .payment(type: .card(transactionInformation: accessCodeResponse))
52-
}
36+
transactionDetails = response
37+
transactionState = nextState(for: supported, response: response)
5338
} catch {
5439
let error = ChargeError(error: error)
5540
Logger.error("Verify access code failed with error: %@",
@@ -59,24 +44,62 @@ class ChargeViewModel: ObservableObject {
5944
}
6045
}
6146

62-
}
47+
private func resolveSupportedChannels(from response: VerifyAccessCode) -> [SupportedChannel] {
48+
var result: [SupportedChannel] = []
49+
50+
if response.paymentChannels.contains(.card) {
51+
result.append(.card)
52+
}
53+
54+
if response.paymentChannels.contains(.mobileMoney),
55+
let providers = response.channelOptions?.mobileMoney, !providers.isEmpty {
56+
let allowed = filtered(providers)
57+
result.append(contentsOf: allowed.map { .mobileMoney($0) })
58+
}
59+
60+
return result
61+
}
6362

64-
enum SupportedChannels: String, CaseIterable {
65-
case CARD = "CARD"
66-
case MPESA = "MPESA"
67-
case unsupportedChannel
68-
69-
var image: Image {
70-
switch self {
71-
case .CARD:
72-
return Image("cardLogo", bundle: .current)
73-
case .MPESA:
74-
return Image("kenyaSHLogo", bundle: .current)
75-
case .unsupportedChannel:
76-
return Image(systemName: "exclamationmark.triangle.fill")
63+
private func filtered(_ providers: [MobileMoneyChannel]) -> [MobileMoneyChannel] {
64+
guard let allowlist = Self.supportedMobileMoneyProviders else {
65+
return providers
7766
}
67+
return providers.filter { allowlist.contains($0.key.uppercased()) }
7868
}
7969

70+
private func nextState(for channels: [SupportedChannel],
71+
response: VerifyAccessCode) -> ChargeState {
72+
if channels.count == 1, case .card = channels[0] {
73+
return .payment(type: .card(transactionInformation: response))
74+
}
75+
76+
if !channels.contains(.card),
77+
channels.count == 1,
78+
case .mobileMoney(let provider) = channels[0] {
79+
return .payment(type: .mobileMoney(transactionInformation: response,
80+
provider: provider))
81+
}
82+
83+
return .channelSelection(transactionInformation: response,
84+
supportedChannels: channels)
85+
}
86+
87+
}
88+
89+
// MARK: - Mobile money provider allowlist
90+
extension ChargeViewModel {
91+
92+
/// Mobile money provider keys (`MobileMoneyChannel.key`, uppercased) that
93+
/// the SDK is allowed to route to. The API may return providers we don't
94+
/// yet have logos, copy, or phone formatters for — listing them here is
95+
/// what opts them into the UI.
96+
///
97+
/// Set to `nil` to accept every provider the API returns (no filtering).
98+
/// Add a new key here when you've added its logo to `SupportedChannel.image`
99+
/// and its country code / phone formatter to the relevant helpers.
100+
static var supportedMobileMoneyProviders: Set<String>? = [
101+
"MPESA"
102+
]
80103
}
81104

82105
// MARK: - Charge Container

Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
1313
var chargeCardContainer: ChargeContainer
1414
var repository: ChargeMobileMoneyRepository
1515
var transactionDetails: VerifyAccessCode
16+
let provider: MobileMoneyChannel
1617
@Published
1718
var phoneNumber: String = ""
1819

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

2223
init(chargeCardContainer: ChargeContainer,
2324
transactionDetails: VerifyAccessCode,
25+
provider: MobileMoneyChannel,
2426
repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) {
2527
self.chargeCardContainer = chargeCardContainer
2628
self.repository = repository
2729
self.transactionDetails = transactionDetails
30+
self.provider = provider
2831
}
2932

3033
var isValid: Bool {
@@ -37,7 +40,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
3740
let authenticationResult = try await repository.chargeMobileMoney(
3841
phone: phoneNumber.formattedKenyanPhoneNumber,
3942
transactionId: "\(transactionDetails.transactionId ?? 0)",
40-
provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "")
43+
provider: provider.key)
4144
transactionState = .processTransaction(transaction: authenticationResult)
4245
} catch {
4346
displayTransactionError(ChargeError(error: error))

Sources/PaystackUI/Charge/MobileMoney/Views/ChannelSelectionView.swift

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,14 @@ struct ChannelSelectionView: View {
1414
var visibilityContainer: ViewVisibilityContainer
1515
@StateObject
1616
var viewModel: ChannelSelectionViewModel
17+
let supportedChannels: [SupportedChannel]
1718
let columns = [GridItem(.flexible()), GridItem(.flexible())]
18-
var items: [PaymentChannel] = []
19+
1920
init(state: Binding<ChargeState>,
20-
supportedChannels: [SupportedChannels],
21+
supportedChannels: [SupportedChannel],
2122
information: VerifyAccessCode) {
2223
self._viewModel = StateObject(wrappedValue: ChannelSelectionViewModel(state: state, information: information))
23-
items = supportedChannels.map {
24-
PaymentChannel(channel: $0)
25-
}
24+
self.supportedChannels = supportedChannels
2625
}
2726

2827
var body: some View {
@@ -35,13 +34,13 @@ struct ChannelSelectionView: View {
3534
.multilineTextAlignment(.center)
3635
GeometryReader { geo in
3736
LazyVGrid(columns: columns) {
38-
ForEach(items) { value in
39-
ChannelView(channelTitle: value.title, image: value.image)
37+
ForEach(supportedChannels) { channel in
38+
ChannelView(channelTitle: channel.displayTitle, image: channel.image)
4039
.padding(.singlePadding)
4140
.onTapGesture {
42-
viewModel.chooseChannel(channel: value.channel)
41+
viewModel.choose(channel)
4342
}
44-
.frame(width: (geo.size.width / CGFloat(items.count)).rounded())
43+
.frame(width: (geo.size.width / CGFloat(supportedChannels.count)).rounded())
4544
}
4645
}
4746
}
@@ -59,31 +58,14 @@ class ChannelSelectionViewModel: ObservableObject {
5958
self.information = information
6059
}
6160

62-
func chooseChannel(channel: SupportedChannels) {
63-
let message = "Card/MPesa payments are not supported. " +
64-
"Please reach out to your merchant for further information"
65-
let cause = "There are currently no payment channels on " +
66-
"your integration that are supported by the SDK"
61+
func choose(_ channel: SupportedChannel) {
6762
switch channel {
68-
case .CARD:
63+
case .card:
6964
state = .payment(type: .card(transactionInformation: self.information))
70-
case .MPESA:
71-
state = .payment(type: .mobileMoney(transactionInformation: self.information))
72-
case .unsupportedChannel:
73-
state = .error(ChargeError(displayMessage: message, causeMessage: cause))
65+
case .mobileMoney(let provider):
66+
state = .payment(type: .mobileMoney(transactionInformation: self.information,
67+
provider: provider))
7468
}
75-
76-
}
77-
}
78-
79-
struct PaymentChannel: Identifiable {
80-
var id: String = UUID().uuidString
81-
let channel: SupportedChannels
82-
var title: String {
83-
channel.rawValue
84-
}
85-
var image: Image {
86-
channel.image
8769
}
8870
}
8971

Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ struct MPesaChargeView: View {
1010

1111
init(
1212
chargeCardContainer: ChargeContainer,
13-
transactionDetails: VerifyAccessCode) {
13+
transactionDetails: VerifyAccessCode,
14+
provider: MobileMoneyChannel) {
1415
self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel(
15-
chargeCardContainer: chargeCardContainer, transactionDetails: transactionDetails))
16+
chargeCardContainer: chargeCardContainer,
17+
transactionDetails: transactionDetails,
18+
provider: provider))
1619
}
1720

1821
var body: some View {

Sources/PaystackUI/Charge/Models/ChargePaymentType.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import Foundation
33
// TODO: Add an extension to map from payment channels once those are defined
44
enum ChargePaymentType: Equatable {
55
case card(transactionInformation: VerifyAccessCode)
6-
case mobileMoney(transactionInformation: VerifyAccessCode)
6+
case mobileMoney(transactionInformation: VerifyAccessCode,
7+
provider: MobileMoneyChannel)
78
}

Sources/PaystackUI/Charge/Models/ChargeState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import PaystackCore
44
enum ChargeState {
55
case loading(message: String? = nil)
66
case payment(type: ChargePaymentType)
7-
case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannels])
7+
case channelSelection (transactionInformation: VerifyAccessCode, supportedChannels: [SupportedChannel])
88
case error(ChargeError)
99
case success(amount: AmountCurrency, merchant: String,
1010
details: ChargeCompletionDetails)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import SwiftUI
2+
import PaystackCore
3+
4+
/// A resolved payment channel that the SDK is willing to route to for the
5+
/// current transaction. One entry per card option, plus one entry per
6+
/// supported mobile money provider returned by `verifyAccessCode`.
7+
enum SupportedChannel: Equatable, Identifiable {
8+
case card
9+
case mobileMoney(MobileMoneyChannel)
10+
11+
var id: String {
12+
switch self {
13+
case .card:
14+
return "card"
15+
case .mobileMoney(let channel):
16+
return "mobile_money.\(channel.key)"
17+
}
18+
}
19+
20+
var displayTitle: String {
21+
switch self {
22+
case .card:
23+
return "Card"
24+
case .mobileMoney(let channel):
25+
return channel.value
26+
}
27+
}
28+
29+
var image: Image {
30+
switch self {
31+
case .card:
32+
return Image("cardLogo", bundle: .current)
33+
case .mobileMoney(let channel):
34+
return Self.image(forMobileMoneyKey: channel.key)
35+
}
36+
}
37+
38+
/// Maps known Paystack mobile money provider keys to a bundled logo.
39+
/// Falls back to a generic SF Symbol when the SDK has no logo for the
40+
/// provider yet — keeps the channel-selection screen renderable when a
41+
/// future provider lights up via the allowlist.
42+
private static func image(forMobileMoneyKey key: String) -> Image {
43+
switch key.uppercased() {
44+
case "MPESA":
45+
return Image("kenyaSHLogo", bundle: .current)
46+
default:
47+
return Image(systemName: "creditcard")
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)