diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift index 598f8e2..c0b7130 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferConfig.swift @@ -6,4 +6,16 @@ struct BankTransferConfig: Equatable { let transactionId: Int let availableProviders: [String] + + let provider: BankTransferProvider + + init(fulfilLateNotification: Bool, + transactionId: Int, + availableProviders: [String], + provider: BankTransferProvider = .standard) { + self.fulfilLateNotification = fulfilLateNotification + self.transactionId = transactionId + self.availableProviders = availableProviders + self.provider = provider + } } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift new file mode 100644 index 0000000..94e285a --- /dev/null +++ b/Sources/PaystackUI/Charge/BankTransfer/Models/BankTransferProvider.swift @@ -0,0 +1,6 @@ +import Foundation + +enum BankTransferProvider: Equatable { + case standard + case pesalink +} diff --git a/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift index a3d57a8..662f925 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Viewmodels/BankTransferViewModel.swift @@ -120,8 +120,6 @@ class BankTransferViewModel: ObservableObject { await provisionVirtualAccount() } - // MARK: - Bank picker - var availableBankSlugs: [String] { config.availableProviders } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift index d33cbf1..b2cb1aa 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift @@ -5,7 +5,7 @@ struct BankTransferAccountDetailsView: View { let details: BankTransferDetails let amount: AmountCurrency - let onChangeBank: (() -> Void)? + let provider: BankTransferProvider let onIveSentTheMoney: () async -> Void @State private var provisionedAt: Date = Date() @@ -15,11 +15,11 @@ struct BankTransferAccountDetailsView: View { init(details: BankTransferDetails, amount: AmountCurrency, - onChangeBank: (() -> Void)? = nil, + provider: BankTransferProvider, onIveSentTheMoney: @escaping () async -> Void) { self.details = details self.amount = amount - self.onChangeBank = onChangeBank + self.provider = provider self.onIveSentTheMoney = onIveSentTheMoney } @@ -48,8 +48,7 @@ struct BankTransferAccountDetailsView: View { private var accountCard: some View { VStack(spacing: 0) { AccountDetailRow(label: "BANK NAME", - value: details.bankName, - trailing: bankNameTrailing) + value: details.bankName) divider AccountDetailRow(label: "ACCOUNT NUMBER", value: details.accountNumber, @@ -58,6 +57,13 @@ struct BankTransferAccountDetailsView: View { AccountDetailRow(label: "AMOUNT", value: amount.description, trailing: .copy) + + if provider == .pesalink { + divider + AccountDetailRow(label: "NARRATION / REASON", + value: details.transactionReference, + trailing: .copy) + } } .overlay( RoundedRectangle(cornerRadius: .cornerRadius) @@ -65,13 +71,6 @@ struct BankTransferAccountDetailsView: View { ) } - private var bankNameTrailing: AccountDetailRow.Trailing { - if let handler = onChangeBank { - return .text("Change bank", handler) - } - return .none - } - private var divider: some View { Rectangle() .fill(Color.navy05) diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift index 2cb9ff4..1585a79 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift @@ -6,8 +6,6 @@ struct BankTransferView: View { @StateObject var viewModel: BankTransferViewModel - @State private var showBankPicker = false - init(chargeContainer: ChargeContainer, transactionDetails: VerifyAccessCode, config: BankTransferConfig) { @@ -26,9 +24,7 @@ struct BankTransferView: View { BankTransferAccountDetailsView( details: details, amount: viewModel.transactionDetails.amountCurrency, - onChangeBank: viewModel.availableBankSlugs.isEmpty - ? nil - : { showBankPicker = true }, + provider: viewModel.config.provider, onIveSentTheMoney: { await viewModel.userTappedIveSentTheMoney() }) .id(details.transactionReference) case .confirmingPayment(let details, let phase): @@ -70,18 +66,6 @@ struct BankTransferView: View { } } .task(viewModel.provisionVirtualAccount) - .sheet(isPresented: $showBankPicker) { - BankPickerSheet( - availableSlugs: viewModel.availableBankSlugs, - currentSlug: viewModel.currentBankSlug, - onSelect: { slug in - Task { @MainActor [viewModel] in - await viewModel.userSelectedBank(slug: slug) - } - showBankPicker = false - }, - onCancel: { showBankPicker = false }) - } } private var showsChangePaymentMethodFooter: Bool { diff --git a/Sources/PaystackUI/Charge/ChargeViewModel.swift b/Sources/PaystackUI/Charge/ChargeViewModel.swift index 4b1a86a..6a24c7c 100644 --- a/Sources/PaystackUI/Charge/ChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/ChargeViewModel.swift @@ -58,11 +58,15 @@ class ChargeViewModel: ObservableObject { if response.paymentChannels.contains(.bankTransfer), let transactionId = response.transactionId { + let provider: BankTransferProvider = + Self.pesalinkCurrencyCodes.contains(response.currency) + ? .pesalink : .standard let config = BankTransferConfig( fulfilLateNotification: response .merchantChannelSettings?.bankTransfer?.fulfilLateNotification ?? false, transactionId: transactionId, - availableProviders: response.channelOptions?.bankTransfer ?? []) + availableProviders: response.channelOptions?.bankTransfer ?? [], + provider: provider) result.append(.bankTransfer(config)) } @@ -126,6 +130,7 @@ extension ChargeViewModel { ] static var promotedSupportedBankCodes: Set = ["00zap"] + static var pesalinkCurrencyCodes: Set = ["KES"] } extension ChargeViewModel: ChargeContainer { diff --git a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift index 95c5c43..a40194b 100644 --- a/Sources/PaystackUI/Charge/Models/SupportedChannel.swift +++ b/Sources/PaystackUI/Charge/Models/SupportedChannel.swift @@ -6,7 +6,7 @@ enum SupportedChannel: Equatable, Identifiable { case mobileMoney(MobileMoneyChannel) case bankTransfer(BankTransferConfig) case zap(ZapConfig) - + var id: String { switch self { case .card: @@ -19,33 +19,35 @@ enum SupportedChannel: Equatable, Identifiable { return "zap" } } - + var displayTitle: String { switch self { case .card: return "Card" case .mobileMoney(let channel): return channel.value - case .bankTransfer: - return "Bank Transfer" + case .bankTransfer(let config): + return config.provider == .pesalink ? "Pesalink" : "Bank Transfer" case .zap: return "Zap" } } - + var image: Image { switch self { case .card: return Image("cardLogo", bundle: .current) case .mobileMoney(let channel): return Self.image(forMobileMoneyKey: channel.key) - case .bankTransfer: - return Image("bankTransferLogo", bundle: .current) + case .bankTransfer(let config): + return config.provider == .pesalink + ? Image("pesalinkLogo", bundle: .current) + : Image("bankTransferLogo", bundle: .current) case .zap: return Image("zapSingleLogo", bundle: .current) } } - + private static func image(forMobileMoneyKey key: String) -> Image { switch key.uppercased() { case "MPESA": diff --git a/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json new file mode 100644 index 0000000..34173e7 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Group 85.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg new file mode 100644 index 0000000..e71de7c --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/pesalinkLogo.imageset/Group 85.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift index 16fb8c7..1899b99 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ChargeViewModelTests.swift @@ -233,6 +233,108 @@ final class ChargeViewModelTests: PSTestCase { config: expectedConfig))) } + // MARK: - Pesalink branding (PR K-B) + + /// KES currency → bank-transfer config resolves with `.pesalink` + /// provider, which drives the Pesalink tile + Narration row. + func testBankTransferResolvesToPesalinkProviderForKES() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "KES", + fulfilLateNotification: false, + transactionId: 5678) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .pesalink) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// NGN currency → standard PWT provider, not Pesalink. + func testBankTransferResolvesToStandardProviderForNGN() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "NGN", + bankTransferProviders: ["wema-bank"], + fulfilLateNotification: true, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .standard) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// Regression guard: USD (or any other non-KES currency) → standard. + /// Catches accidental over-broadening of the Pesalink gate. + func testBankTransferResolvesToStandardProviderForUSD() async { + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "USD", + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .standard) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// The `pesalinkCurrencyCodes` static is runtime-mutable so the + /// gate can be widened to a new currency without an SDK release. + func testPesalinkCurrencyCodesStaticIsConfigurable() async { + let originalCodes = ChargeViewModel.pesalinkCurrencyCodes + defer { ChargeViewModel.pesalinkCurrencyCodes = originalCodes } + ChargeViewModel.pesalinkCurrencyCodes = ["KES", "UGX"] + + let response = VerifyAccessCode.with( + channels: [.bankTransfer], + currency: "UGX", + fulfilLateNotification: false, + transactionId: 1234) + mockRepo.expectedVerifyAccessCode = response + + await serviceUnderTest.verifyAccessCodeAndProceed() + + if case .payment(.bankTransfer(_, let config)) = serviceUnderTest.transactionState { + XCTAssertEqual(config.provider, .pesalink) + } else { + XCTFail("Expected .payment(.bankTransfer(_, _)) state, got \(serviceUnderTest.transactionState)") + } + } + + /// Confirms `SupportedChannel.bankTransfer(.pesalink)` produces the + /// Pesalink-branded display title. + func testPesalinkProviderProducesPesalinkDisplayTitle() { + let pesalinkConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: [], + provider: .pesalink) + let standardConfig = BankTransferConfig( + fulfilLateNotification: false, + transactionId: 1234, + availableProviders: [], + provider: .standard) + + XCTAssertEqual(SupportedChannel.bankTransfer(pesalinkConfig).displayTitle, + "Pesalink") + XCTAssertEqual(SupportedChannel.bankTransfer(standardConfig).displayTitle, + "Bank Transfer") + } + func testBankTransferDoesNotAppearWhenChannelArrayOmitsIt() async { let response = VerifyAccessCode.with( channels: [.card], @@ -492,6 +594,7 @@ private extension MobileMoneyChannel { private extension VerifyAccessCode { static func with(channels: [PaystackCore.Channel], email: String = "customer@example.com", + currency: String = "USD", mobileMoney: [MobileMoneyChannel]? = nil, bankTransferProviders: [String]? = nil, fulfilLateNotification: Bool? = nil, @@ -508,7 +611,7 @@ private extension VerifyAccessCode { } return VerifyAccessCode(email: email, amount: 10000, - currency: "USD", + currency: currency, accessCode: "test_access", paymentChannels: channels, domain: .test,