Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions Sources/PaystackUI/Charge/ChargeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ struct ChargeView: View {
ChargeCardView(transactionDetails: transactionInformation,
chargeContainer: viewModel)
case .mobileMoney(let transactionInformation, let provider):
MPesaChargeView(chargeCardContainer: viewModel,
transactionDetails: transactionInformation,
provider: provider)
MobileMoneyFlowFactory.view(for: provider,
chargeContainer: viewModel,
transactionDetails: transactionInformation)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/PaystackUI/Charge/ChargeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ extension ChargeViewModel {
/// 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"
"MPESA", "ATL_KE", "MTN", "ATL", "VOD"
]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import SwiftUI

@available(iOS 14.0, *)
enum MobileMoneyFlowFactory {

@ViewBuilder
static func view(for provider: MobileMoneyChannel,
chargeContainer: ChargeContainer,
transactionDetails: VerifyAccessCode) -> some View {

MobileMoneyChargeView(chargeCardContainer: chargeContainer,
transactionDetails: transactionDetails,
provider: provider)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SwiftUI
import PaystackCore

struct MobileMoneyChannel: Equatable {
Expand All @@ -20,3 +21,32 @@ extension MobileMoneyChannel {
.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}$")
}
}

// MARK: - Provider-aware UI helpers

extension MobileMoneyChannel {

var expectedCountryCode: String {
switch key.uppercased() {
case "MPESA", "ATL_KE":
return "254"
case "MTN", "ATL", "VOD": // Ghana
return "233"
case "WAVE_CI", "ORANGE_CI", "MTN_CI": // Côte d'Ivoire
return "225"
default:
return ""
}
}

var phoneInputAccessory: AnyView? {
switch key.uppercased() {
case "MPESA", "ATL_KE":
return AnyView(Image.kenyaFlagLogo)
case "MTN", "ATL", "VOD":
return AnyView(Image.ghanaFlagLogo)
default:
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PaystackCore

protocol ChargeMobileMoneyRepository {
func chargeMobileMoney(phone: String, transactionId: String, provider: String) async throws -> MobileMoneyTransaction
func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction
func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction
func checkPendingCharge(with accessCode: String) async throws -> ChargeCardTransaction
}

Expand All @@ -21,7 +21,7 @@ struct ChargeMobileMoneyRepositoryImplementation: ChargeMobileMoneyRepository {
return MobileMoneyTransaction.from(response)
}

func listenForMPesa(for transactionId: Int) async throws -> ChargeCardTransaction {
func listenForMobileMoneyResponse(for transactionId: Int) async throws -> ChargeCardTransaction {
let response = try await paystack.listenForMobileMoneyResponse(for: transactionId).async()
return ChargeCardTransaction.from(response)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import Foundation
import PaystackCore

protocol MPesaContainer {
protocol MobileMoneyContainer {
var transactionDetails: VerifyAccessCode { get }
var provider: MobileMoneyChannel { get }
func processTransactionResponse(_ response: ChargeCardTransaction) async
func displayTransactionError(_ error: ChargeError)
func restartMPesaPayment()
func restartMobileMoneyPayment()
}

class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
class MobileMoneyChargeViewModel: ObservableObject, @MainActor MobileMoneyContainer {

var chargeCardContainer: ChargeContainer
var repository: ChargeMobileMoneyRepository
Expand All @@ -31,14 +32,20 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
}

var isValid: Bool {
phoneNumber.count >= 10
if !provider.phoneNumberRegex.isEmpty,
let regex = try? NSRegularExpression(pattern: provider.phoneNumberRegex) {
let formatted = phoneNumber.formatted(for: provider)
let range = NSRange(location: 0, length: formatted.utf16.count)
return regex.firstMatch(in: formatted, range: range) != nil
}
return phoneNumber.count >= 10
}

@MainActor
func submitPhoneNumber() async {
do {
let authenticationResult = try await repository.chargeMobileMoney(
phone: phoneNumber.formattedKenyanPhoneNumber,
phone: phoneNumber.formatted(for: provider),
transactionId: "\(transactionDetails.transactionId ?? 0)",
provider: provider.key)
transactionState = .processTransaction(transaction: authenticationResult)
Expand All @@ -47,7 +54,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
}
}

func restartMPesaPayment() {
func restartMobileMoneyPayment() {
transactionState = .countdown
}

Expand All @@ -65,7 +72,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
case .pending:
break
default:
Logger.error("Unexpected M-Pesa transaction status: %@",
Logger.error("Unexpected mobile money transaction status: %@",
arguments: response.status.rawValue)
transactionState = .fatalError(
error: .generic(withCause: "Unexpected transaction status: \(response.status.rawValue)"))
Expand All @@ -79,7 +86,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
}

func cancelTransaction() {
restartMPesaPayment()
restartMobileMoneyPayment()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Foundation
import PaystackCore

class MPesaProcessingViewModel: ObservableObject {
class MobileMoneyProcessingViewModel: ObservableObject {

var container: MPesaContainer
var container: MobileMoneyContainer
var repository: ChargeMobileMoneyRepository
let mobileMoneyTransaction: MobileMoneyTransaction
@Published
var counter = 0

init(container: MPesaContainer,
init(container: MobileMoneyContainer,
mobileMoneyTransaction: MobileMoneyTransaction,
repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) {
self.container = container
Expand All @@ -21,20 +21,27 @@ class MPesaProcessingViewModel: ObservableObject {
container.transactionDetails
}

var authorizationPromptText: String {
if !mobileMoneyTransaction.message.isEmpty {
return mobileMoneyTransaction.message
}
return "Please authorize the payment with \(container.provider.value) on your phone"
}

func checkTransactionStatus() {
Task {
await checkPendingCharge()
}
}

@MainActor
func initializeMPesaAuthorization() async {
func initializeMobileMoneyAuthorization() async {
do {
let authenticationResult = try await repository.listenForMPesa(
let authenticationResult = try await repository.listenForMobileMoneyResponse(
for: Int(mobileMoneyTransaction.transaction) ?? 0)
await container.processTransactionResponse(authenticationResult)
} catch {
Logger.error("Listening for M-Pesa transaction failed with error: %@",
Logger.error("Listening for mobile money transaction failed with error: %@",
arguments: error.localizedDescription)
container.displayTransactionError(ChargeError(error: error))
}
Expand All @@ -47,14 +54,14 @@ class MPesaProcessingViewModel: ObservableObject {
with: transactionDetails.accessCode)
await container.processTransactionResponse(authenticationResult)
} catch {
Logger.error("Checking pending M-Pesa charge failed with error: %@",
Logger.error("Checking pending mobile money charge failed with error: %@",
arguments: error.localizedDescription)
container.displayTransactionError(ChargeError(error: error))
}
}

func cancelTransaction() {
container.restartMPesaPayment()
container.restartMobileMoneyPayment()
}

}
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import SwiftUI

@available(iOS 14.0, *)
struct MPesaChargeView: View {
struct MobileMoneyChargeView: View {

@StateObject
var viewModel: MPesaChrageViewModel
var viewModel: MobileMoneyChargeViewModel
private let phoneNumberMaximumLength = 15
@State private var showPhoneNumberError = false

init(
chargeCardContainer: ChargeContainer,
transactionDetails: VerifyAccessCode,
provider: MobileMoneyChannel) {
self._viewModel = StateObject(wrappedValue: MPesaChrageViewModel(
self._viewModel = StateObject(wrappedValue: MobileMoneyChargeViewModel(
chargeCardContainer: chargeCardContainer,
transactionDetails: transactionDetails,
provider: provider))
Expand All @@ -25,15 +25,15 @@ struct MPesaChargeView: View {
case .error(let chargeError):
ErrorView(message: chargeError.message,
buttonText: "Try again",
buttonAction: viewModel.restartMPesaPayment)
buttonAction: viewModel.restartMobileMoneyPayment)
case .fatalError(let error):
ErrorView(message: error.message,
automaticallyDismissWith: .init(
error: error,
transactionReference: viewModel.transactionDetails.reference))
case .processTransaction(let transaction):
MPesaProcessingView(container: viewModel,
mobileMoneyTransaction: transaction)
MobileMoneyProcessingView(container: viewModel,
mobileMoneyTransaction: transaction)
case .countdown:
VStack(spacing: .triplePadding) {

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

@ViewBuilder
var phoneNumber: some FormInputItemView {

TextFieldFormInputView(title: "Phone Number",
placeholder: "070 000 0000",
text: $viewModel.phoneNumber,
keyboardType: .phonePad,
maxLength: phoneNumberMaximumLength,
inErrorState: $showPhoneNumberError,
defaultFocused: true,
accessoryView: Image.kenyaFlagLogo)
accessoryView: viewModel.provider.phoneInputAccessory)
.minLength(10, errorMessage: "Invalid Phone Number")
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import SwiftUI

@available(iOS 14.0, *)
struct MPesaProcessingView: View {
struct MobileMoneyProcessingView: View {

@StateObject
var viewModel: MPesaProcessingViewModel
var viewModel: MobileMoneyProcessingViewModel

init(container: MPesaContainer,
init(container: MobileMoneyContainer,
mobileMoneyTransaction: MobileMoneyTransaction) {
self._viewModel = StateObject(wrappedValue: MPesaProcessingViewModel(
self._viewModel = StateObject(wrappedValue: MobileMoneyProcessingViewModel(
container: container,
mobileMoneyTransaction: mobileMoneyTransaction))
}
Expand All @@ -23,7 +23,7 @@ struct MPesaProcessingView: View {
.foregroundColor(.stackBlue)
.multilineTextAlignment(.center)

Text("Please enter your pin on your phone to complete this payment")
Text(viewModel.authorizationPromptText)
.font(.body16M)
.foregroundColor(.stackBlue)
.multilineTextAlignment(.center)
Expand All @@ -33,7 +33,7 @@ struct MPesaProcessingView: View {
action: viewModel.checkTransactionStatus)
}
.padding(.doublePadding)
.task(viewModel.initializeMPesaAuthorization)
.task(viewModel.initializeMobileMoneyAuthorization)
}
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/PaystackUI/Charge/Models/SupportedChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ enum SupportedChannel: Equatable, Identifiable {
/// future provider lights up via the allowlist.
private static func image(forMobileMoneyKey key: String) -> Image {
switch key.uppercased() {
case "MPESA":
case "MPESA", "ATL_KE":
return Image("kenyaSHLogo", bundle: .current)
case "MTN", "ATL", "VOD":
return Image("kenyaSHLogo", bundle: .current)
default:
return Image(systemName: "creditcard")
Expand Down
5 changes: 5 additions & 0 deletions Sources/PaystackUI/Images/Images.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ extension Image {
Image("kenyaFlagLogo", bundle: .current)
.frame(height: 16)
}

static var ghanaFlagLogo: some View {
Image("ghanaFlagLogo", bundle: .current)
.frame(height: 16)
}

static var messageBubbleLogo: some View {
Image("messageBubbleLogo", bundle: .current)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Flags.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions Sources/PaystackUI/Utils/StringExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@ extension String {
from: self)
}

var formattedKenyanPhoneNumber: String {
func formatted(for provider: MobileMoneyChannel) -> String {
let trimmed = self.removingAllWhitespaces
if trimmed.hasPrefix("+254") {
let countryCode = provider.expectedCountryCode

guard !countryCode.isEmpty else { return trimmed }

if trimmed.hasPrefix("+\(countryCode)") {
return trimmed
}
if trimmed.hasPrefix("254") {
if trimmed.hasPrefix(countryCode) {
return "+" + trimmed
}
if trimmed.hasPrefix("0") {
return "+254" + trimmed.dropFirst()
return "+\(countryCode)" + trimmed.dropFirst()
}
return trimmed
}
Expand Down
Loading
Loading