Skip to content

Commit 7cabff4

Browse files
Generalize M-Pesa flow into multi-provider mobile money support (#122)
* Generalize M-Pesa flow into multi-provider mobile money support Rename MPesa* types/files to MobileMoney* and add a MobileMoneyFlowFactory so additional providers can plug into the same charge flow. Adds provider-aware UI helpers (expected country code, flag accessory) on MobileMoneyChannel, a Ghana flag asset, and accompanying tests. * Updating tests to reflect the correct keys
1 parent f15aa92 commit 7cabff4

21 files changed

Lines changed: 361 additions & 104 deletions

Sources/PaystackUI/Charge/ChargeView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ struct ChargeView: View {
6060
ChargeCardView(transactionDetails: transactionInformation,
6161
chargeContainer: viewModel)
6262
case .mobileMoney(let transactionInformation, let provider):
63-
MPesaChargeView(chargeCardContainer: viewModel,
64-
transactionDetails: transactionInformation,
65-
provider: provider)
63+
MobileMoneyFlowFactory.view(for: provider,
64+
chargeContainer: viewModel,
65+
transactionDetails: transactionInformation)
6666
}
6767
}
6868

Sources/PaystackUI/Charge/ChargeViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ extension ChargeViewModel {
9898
/// Add a new key here when you've added its logo to `SupportedChannel.image`
9999
/// and its country code / phone formatter to the relevant helpers.
100100
static var supportedMobileMoneyProviders: Set<String>? = [
101-
"MPESA"
101+
"MPESA", "ATL_KE", "MTN", "ATL", "VOD"
102102
]
103103
}
104104

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import SwiftUI
2+
3+
@available(iOS 14.0, *)
4+
enum MobileMoneyFlowFactory {
5+
6+
@ViewBuilder
7+
static func view(for provider: MobileMoneyChannel,
8+
chargeContainer: ChargeContainer,
9+
transactionDetails: VerifyAccessCode) -> some View {
10+
11+
MobileMoneyChargeView(chargeCardContainer: chargeContainer,
12+
transactionDetails: transactionDetails,
13+
provider: provider)
14+
}
15+
}

Sources/PaystackUI/Charge/MobileMoney/Models/MobileMoneyChannel.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import SwiftUI
23
import PaystackCore
34

45
struct MobileMoneyChannel: Equatable {
@@ -20,3 +21,32 @@ extension MobileMoneyChannel {
2021
.init(key: "MPESA", value: "M-PESA", isNew: true, phoneNumberRegex: "^\\+254(7([0-2]\\d|4\\d|5(7|8|9)|6(8|9)|9[0-9])|(11\\d))\\d{6}$")
2122
}
2223
}
24+
25+
// MARK: - Provider-aware UI helpers
26+
27+
extension MobileMoneyChannel {
28+
29+
var expectedCountryCode: String {
30+
switch key.uppercased() {
31+
case "MPESA", "ATL_KE":
32+
return "254"
33+
case "MTN", "ATL", "VOD": // Ghana
34+
return "233"
35+
case "WAVE_CI", "ORANGE_CI", "MTN_CI": // Côte d'Ivoire
36+
return "225"
37+
default:
38+
return ""
39+
}
40+
}
41+
42+
var phoneInputAccessory: AnyView? {
43+
switch key.uppercased() {
44+
case "MPESA", "ATL_KE":
45+
return AnyView(Image.kenyaFlagLogo)
46+
case "MTN", "ATL", "VOD":
47+
return AnyView(Image.ghanaFlagLogo)
48+
default:
49+
return nil
50+
}
51+
}
52+
}

Sources/PaystackUI/Charge/MobileMoney/Repository/ChargeMobileMoneyRepository.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import PaystackCore
33

44
protocol ChargeMobileMoneyRepository {
55
func chargeMobileMoney(phone: String, transactionId: String, provider: String) async throws -> MobileMoneyTransaction
6-
func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction
6+
func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction
77
func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction
88
}
99

@@ -21,7 +21,7 @@ struct ChargeMobileMoneyRepositoryImplementation: ChargeMobileMoneyRepository {
2121
return MobileMoneyTransaction.from(response)
2222
}
2323

24-
func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction {
24+
func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction {
2525
let response = try await paystack.listenForMobileMoneyResponse(for: transactionId).async()
2626
return ChargeCardTransaction.from(response)
2727
}

Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaChrageViewModel.swift renamed to Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import Foundation
22
import PaystackCore
33

4-
protocol MPesaContainer {
4+
protocol MobileMoneyContainer {
55
var transactionDetails: VerifyAccessCode { get }
6+
var provider: MobileMoneyChannel { get }
67
func processTransactionResponse(_ response: ChargeCardTransaction) async
78
func displayTransactionError(_ error: ChargeError)
8-
func restartMPesaPayment()
9+
func restartMobileMoneyPayment()
910
}
1011

11-
class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
12+
class MobileMoneyChargeViewModel: ObservableObject, @MainActor MobileMoneyContainer {
1213

1314
var chargeCardContainer: ChargeContainer
1415
var repository: ChargeMobileMoneyRepository
@@ -31,14 +32,20 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
3132
}
3233

3334
var isValid: Bool {
34-
phoneNumber.count >= 10
35+
if !provider.phoneNumberRegex.isEmpty,
36+
let regex = try? NSRegularExpression(pattern: provider.phoneNumberRegex) {
37+
let formatted = phoneNumber.formatted(for: provider)
38+
let range = NSRange(location: 0, length: formatted.utf16.count)
39+
return regex.firstMatch(in: formatted, range: range) != nil
40+
}
41+
return phoneNumber.count >= 10
3542
}
3643

3744
@MainActor
3845
func submitPhoneNumber() async {
3946
do {
4047
let authenticationResult = try await repository.chargeMobileMoney(
41-
phone: phoneNumber.formattedKenyanPhoneNumber,
48+
phone: phoneNumber.formatted(for: provider),
4249
transactionId: "\(transactionDetails.transactionId ?? 0)",
4350
provider: provider.key)
4451
transactionState = .processTransaction(transaction: authenticationResult)
@@ -47,7 +54,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
4754
}
4855
}
4956

50-
func restartMPesaPayment() {
57+
func restartMobileMoneyPayment() {
5158
transactionState = .countdown
5259
}
5360

@@ -65,7 +72,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
6572
case .pending:
6673
break
6774
default:
68-
Logger.error("Unexpected M-Pesa transaction status: %@",
75+
Logger.error("Unexpected mobile money transaction status: %@",
6976
arguments: response.status.rawValue)
7077
transactionState = .fatalError(
7178
error: .generic(withCause: "Unexpected transaction status: \(response.status.rawValue)"))
@@ -79,7 +86,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
7986
}
8087

8188
func cancelTransaction() {
82-
restartMPesaPayment()
89+
restartMobileMoneyPayment()
8390
}
8491
}
8592

Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MPesaProcessingViewModel.swift renamed to Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyProcessingViewModel.swift

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import Foundation
22
import PaystackCore
33

4-
class MPesaProcessingViewModel: ObservableObject {
4+
class MobileMoneyProcessingViewModel: ObservableObject {
55

6-
var container: MPesaContainer
6+
var container: MobileMoneyContainer
77
var repository: ChargeMobileMoneyRepository
88
let mobileMoneyTransaction: MobileMoneyTransaction
99
@Published
1010
var counter = 0
1111

12-
init(container: MPesaContainer,
12+
init(container: MobileMoneyContainer,
1313
mobileMoneyTransaction: MobileMoneyTransaction,
1414
repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) {
1515
self.container = container
@@ -21,20 +21,27 @@ class MPesaProcessingViewModel: ObservableObject {
2121
container.transactionDetails
2222
}
2323

24+
var authorizationPromptText: String {
25+
if !mobileMoneyTransaction.message.isEmpty {
26+
return mobileMoneyTransaction.message
27+
}
28+
return "Please authorize the payment with \(container.provider.value) on your phone"
29+
}
30+
2431
func checkTransactionStatus() {
2532
Task {
2633
await checkPendingCharge()
2734
}
2835
}
2936

3037
@MainActor
31-
func initializeMPesaAuthorization() async {
38+
func initializeMobileMoneyAuthorization() async {
3239
do {
33-
let authenticationResult = try await repository.listenForMPesa(
40+
let authenticationResult = try await repository.listenForMobileMoneyResponse(
3441
for: Int(mobileMoneyTransaction.transaction) ?? 0)
3542
await container.processTransactionResponse(authenticationResult)
3643
} catch {
37-
Logger.error("Listening for M-Pesa transaction failed with error: %@",
44+
Logger.error("Listening for mobile money transaction failed with error: %@",
3845
arguments: error.localizedDescription)
3946
container.displayTransactionError(ChargeError(error: error))
4047
}
@@ -47,14 +54,14 @@ class MPesaProcessingViewModel: ObservableObject {
4754
with: transactionDetails.accessCode)
4855
await container.processTransactionResponse(authenticationResult)
4956
} catch {
50-
Logger.error("Checking pending M-Pesa charge failed with error: %@",
57+
Logger.error("Checking pending mobile money charge failed with error: %@",
5158
arguments: error.localizedDescription)
5259
container.displayTransactionError(ChargeError(error: error))
5360
}
5461
}
5562

5663
func cancelTransaction() {
57-
container.restartMPesaPayment()
64+
container.restartMobileMoneyPayment()
5865
}
5966

6067
}

Sources/PaystackUI/Charge/MobileMoney/Views/MPesaChargeView.swift renamed to Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import SwiftUI
22

33
@available(iOS 14.0, *)
4-
struct MPesaChargeView: View {
4+
struct MobileMoneyChargeView: View {
55

66
@StateObject
7-
var viewModel: MPesaChrageViewModel
7+
var viewModel: MobileMoneyChargeViewModel
88
private let phoneNumberMaximumLength = 15
99
@State private var showPhoneNumberError = false
1010

1111
init(
1212
chargeCardContainer: ChargeContainer,
1313
transactionDetails: VerifyAccessCode,
1414
provider: MobileMoneyChannel) {
15-
self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel(
15+
self._viewModel = StateObject(wrappedValue: MobileMoneyChargeViewModel(
1616
chargeCardContainer: chargeCardContainer,
1717
transactionDetails: transactionDetails,
1818
provider: provider))
@@ -25,15 +25,15 @@ struct MPesaChargeView: View {
2525
case .error(let chargeError):
2626
ErrorView(message: chargeError.message,
2727
buttonText: "Try again",
28-
buttonAction: viewModel.restartMPesaPayment)
28+
buttonAction: viewModel.restartMobileMoneyPayment)
2929
case .fatalError(let error):
3030
ErrorView(message: error.message,
3131
automaticallyDismissWith: .init(
3232
error: error,
3333
transactionReference: viewModel.transactionDetails.reference))
3434
case .processTransaction(let transaction):
35-
MPesaProcessingView(container: viewModel,
36-
mobileMoneyTransaction: transaction)
35+
MobileMoneyProcessingView(container: viewModel,
36+
mobileMoneyTransaction: transaction)
3737
case .countdown:
3838
VStack(spacing: .triplePadding) {
3939

@@ -55,14 +55,15 @@ struct MPesaChargeView: View {
5555

5656
@ViewBuilder
5757
var phoneNumber: some FormInputItemView {
58+
5859
TextFieldFormInputView(title: "Phone Number",
5960
placeholder: "070 000 0000",
6061
text: $viewModel.phoneNumber,
6162
keyboardType: .phonePad,
6263
maxLength: phoneNumberMaximumLength,
6364
inErrorState: $showPhoneNumberError,
6465
defaultFocused: true,
65-
accessoryView: Image.kenyaFlagLogo)
66+
accessoryView: viewModel.provider.phoneInputAccessory)
6667
.minLength(10, errorMessage: "Invalid Phone Number")
6768
}
6869
}

Sources/PaystackUI/Charge/MobileMoney/Views/MPesaProcessingView.swift renamed to Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import SwiftUI
22

33
@available(iOS 14.0, *)
4-
struct MPesaProcessingView: View {
4+
struct MobileMoneyProcessingView: View {
55

66
@StateObject
7-
var viewModel: MPesaProcessingViewModel
7+
var viewModel: MobileMoneyProcessingViewModel
88

9-
init(container: MPesaContainer,
9+
init(container: MobileMoneyContainer,
1010
mobileMoneyTransaction: MobileMoneyTransaction) {
11-
self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel(
11+
self._viewModel = StateObject(wrappedValue: MobileMoneyProcessingViewModel(
1212
container: container,
1313
mobileMoneyTransaction: mobileMoneyTransaction))
1414
}
@@ -23,7 +23,7 @@ struct MPesaProcessingView: View {
2323
.foregroundColor(.stackBlue)
2424
.multilineTextAlignment(.center)
2525

26-
Text("Please enter your pin on your phone to complete this payment")
26+
Text(viewModel.authorizationPromptText)
2727
.font(.body16M)
2828
.foregroundColor(.stackBlue)
2929
.multilineTextAlignment(.center)
@@ -33,7 +33,7 @@ struct MPesaProcessingView: View {
3333
action: viewModel.checkTransactionStatus)
3434
}
3535
.padding(.doublePadding)
36-
.task(viewModel.initializeMPesaAuthorization)
36+
.task(viewModel.initializeMobileMoneyAuthorization)
3737
}
3838
}
3939

Sources/PaystackUI/Charge/Models/SupportedChannel.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ enum SupportedChannel: Equatable, Identifiable {
4141
/// future provider lights up via the allowlist.
4242
private static func image(forMobileMoneyKey key: String) -> Image {
4343
switch key.uppercased() {
44-
case "MPESA":
44+
case "MPESA", "ATL_KE":
45+
return Image("kenyaSHLogo", bundle: .current)
46+
case "MTN", "ATL", "VOD":
4547
return Image("kenyaSHLogo", bundle: .current)
4648
default:
4749
return Image(systemName: "creditcard")

0 commit comments

Comments
 (0)