Skip to content

Commit 5531dc0

Browse files
Update release version
- Updated version to 0.1.0
1 parent 9d4672f commit 5531dc0

28 files changed

Lines changed: 658 additions & 195 deletions

PaystackCore.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Pod::Spec.new do |s|
33
s.name = 'PaystackCore'
4-
s.version = '0.0.4'
4+
s.version = '0.1.0'
55
s.summary = 'The Paystack Public iOS SDK'
66

77
# TODO: Add correct descriptions

PaystackUI.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Pod::Spec.new do |s|
33
s.name = 'PaystackUI'
4-
s.version = '0.0.4'
4+
s.version = '0.1.0'
55
s.summary = 'The UI Flows build upon the Paystack Public iOS SDK'
66

77
# TODO: Add correct descriptions

Sources/PaystackSDK/Versioning/versions.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
<plist version="1.0">
44
<dict>
55
<key>Description</key>
6-
<string>Alpine_Swift</string>
6+
<string>0.1.0</string>
77
<key>Version</key>
8-
<string>0.0.4</string>
8+
<string>0.1.0</string>
99
</dict>
1010
</plist>

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+
MobileMoneyFlowFactory.view(for: provider,
64+
chargeContainer: viewModel,
65+
transactionDetails: transactionInformation)
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", "ATL_KE", "MTN", "ATL", "VOD"
102+
]
80103
}
81104

82105
// MARK: - Charge Container
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: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
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
1516
var transactionDetails: VerifyAccessCode
17+
let provider: MobileMoneyChannel
1618
@Published
1719
var phoneNumber: String = ""
1820

@@ -21,30 +23,38 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
2123

2224
init(chargeCardContainer: ChargeContainer,
2325
transactionDetails: VerifyAccessCode,
26+
provider: MobileMoneyChannel,
2427
repository: ChargeMobileMoneyRepository = ChargeMobileMoneyRepositoryImplementation()) {
2528
self.chargeCardContainer = chargeCardContainer
2629
self.repository = repository
2730
self.transactionDetails = transactionDetails
31+
self.provider = provider
2832
}
2933

3034
var isValid: Bool {
31-
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
3242
}
3343

3444
@MainActor
3545
func submitPhoneNumber() async {
3646
do {
3747
let authenticationResult = try await repository.chargeMobileMoney(
38-
phone: phoneNumber.formattedKenyanPhoneNumber,
48+
phone: phoneNumber.formatted(for: provider),
3949
transactionId: "\(transactionDetails.transactionId ?? 0)",
40-
provider: transactionDetails.channelOptions?.mobileMoney?.first?.key ?? "")
50+
provider: provider.key)
4151
transactionState = .processTransaction(transaction: authenticationResult)
4252
} catch {
4353
displayTransactionError(ChargeError(error: error))
4454
}
4555
}
4656

47-
func restartMPesaPayment() {
57+
func restartMobileMoneyPayment() {
4858
transactionState = .countdown
4959
}
5060

@@ -62,7 +72,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
6272
case .pending:
6373
break
6474
default:
65-
Logger.error("Unexpected M-Pesa transaction status: %@",
75+
Logger.error("Unexpected mobile money transaction status: %@",
6676
arguments: response.status.rawValue)
6777
transactionState = .fatalError(
6878
error: .generic(withCause: "Unexpected transaction status: \(response.status.rawValue)"))
@@ -76,7 +86,7 @@ class MPesaChrageViewModel: ObservableObject, @MainActor MPesaContainer {
7686
}
7787

7888
func cancelTransaction() {
79-
restartMPesaPayment()
89+
restartMobileMoneyPayment()
8090
}
8191
}
8292

0 commit comments

Comments
 (0)