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
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 @@
.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 @@
action: viewModel.checkTransactionStatus)
}
.padding(.doublePadding)
.task(viewModel.initializeMPesaAuthorization)
.task(viewModel.initializeMobileMoneyAuthorization)
}
}

Expand Down Expand Up @@ -86,7 +86,7 @@
)
)
.foregroundColor(
(completed() ? Color.stackGreen : Color.stackGreen)

Check warning on line 89 in Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This operation returns the same value whether the condition is "true" or "false".

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AZ5F1mk_Pw7KcwPP0-ji&open=AZ5F1mk_Pw7KcwPP0-ji&pullRequest=122
).animation(
.easeInOut(duration: 0.2)
)
Expand All @@ -105,7 +105,7 @@
struct CountdownView: View {
@Binding var counter: Int
var countTo: Int = 60
var action: () -> Void = {}

Check failure on line 108 in Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyProcessingView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this closure is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AZ5F1mk_Pw7KcwPP0-jj&open=AZ5F1mk_Pw7KcwPP0-jj&pullRequest=122
var body: some View {
VStack {
ZStack {
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