From 7a098dd5bd998d3d09fb0d09e6e2be6763063c22 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Tue, 30 Jun 2026 19:29:26 +0200 Subject: [PATCH] Unify "Change payment method" action across charge flows Replace the standalone ChangePaymentMethodFooter and ad-hoc cancel/close buttons in the bank transfer, mobile money, and Zap flows with a consistent inline "Change payment method" button on each step. - Bank Transfer: add onChangePaymentMethod to the account details, confirming, taking-longer, and delayed-confirmation views; drop the state-driven footer and the separate Close action. - Mobile Money: rename cancelTransaction to userTappedChangePaymentMethod, routing through restartFromChannelSelection. - Zap: restyle the existing button to match (navy02 / body14M). - Add ZapRepositoryImplementationTests covering the standard-host URL, form-urlencoded body, field decoding, and Pusher response mapping. - Update bank transfer and mobile money view-model tests accordingly. --- .../BankTransferAccountDetailsView.swift | 9 +- .../Views/BankTransferConfirmingView.swift | 5 + .../BankTransferDelayedConfirmationView.swift | 4 +- .../Views/BankTransferTakingLongerView.swift | 5 + .../BankTransfer/Views/BankTransferView.swift | 25 +-- .../MobileMoneyChargeViewModel.swift | 5 +- .../Views/MobileMoneyChargeView.swift | 3 +- .../Charge/Zap/Views/ZapPaymentView.swift | 4 +- .../BankTransferViewModelTests.swift | 5 +- .../MobileMoneyChargeViewModelTests.swift | 8 +- .../ZapRepositoryImplementationTests.swift | 162 ++++++++++++++++++ 11 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift index b2cb1aa..e00fadd 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferAccountDetailsView.swift @@ -7,6 +7,7 @@ struct BankTransferAccountDetailsView: View { let amount: AmountCurrency let provider: BankTransferProvider let onIveSentTheMoney: () async -> Void + let onChangePaymentMethod: () -> Void @State private var provisionedAt: Date = Date() @State private var now: Date = Date() @@ -16,11 +17,13 @@ struct BankTransferAccountDetailsView: View { init(details: BankTransferDetails, amount: AmountCurrency, provider: BankTransferProvider, - onIveSentTheMoney: @escaping () async -> Void) { + onIveSentTheMoney: @escaping () async -> Void, + onChangePaymentMethod: @escaping () -> Void) { self.details = details self.amount = amount self.provider = provider self.onIveSentTheMoney = onIveSentTheMoney + self.onChangePaymentMethod = onChangePaymentMethod } var body: some View { @@ -38,6 +41,10 @@ struct BankTransferAccountDetailsView: View { Button("I've sent the money", action: { Task { await onIveSentTheMoney() } }) .buttonStyle(PrimaryButtonStyle(showLoading: false)) + Button("Change payment method", action: onChangePaymentMethod) + .foregroundColor(.navy02) + .font(.body14M) + .padding(.top, .singlePadding) } .padding(.doublePadding) .onReceive(tick) { newNow in diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift index 7fc8a27..4974da2 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferConfirmingView.swift @@ -13,6 +13,7 @@ struct BankTransferConfirmingView: View { let confirmationWindowSeconds: Int let elapsedSeconds: Int let onBackToAccountNumber: () -> Void + let onChangePaymentMethod: () -> Void private var receivedByBackend: Bool { phase == .transferOnTheWay @@ -55,6 +56,10 @@ struct BankTransferConfirmingView: View { Button("Back to account number", action: onBackToAccountNumber) .foregroundColor(.navy02) .font(.body14M) + + Button("Change payment method", action: onChangePaymentMethod) + .foregroundColor(.navy02) + .font(.body14M) } .padding(.doublePadding) .onChange(of: phase) { newPhase in diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift index 3d917e6..69c49d9 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferDelayedConfirmationView.swift @@ -4,7 +4,7 @@ import SwiftUI struct BankTransferDelayedConfirmationView: View { let supportEmail: String - let onClose: () -> Void + let onChangePaymentMethod: () -> Void let onKeepWaiting: () -> Void var body: some View { @@ -29,7 +29,7 @@ struct BankTransferDelayedConfirmationView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, .doublePadding) - Button("Close", action: onClose) + Button("Change payment method", action: onChangePaymentMethod) .buttonStyle(SecondaryButtonStyle()) Button("Keep waiting", action: onKeepWaiting) diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift index 57093e8..5c52538 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferTakingLongerView.swift @@ -6,6 +6,7 @@ struct BankTransferTakingLongerView: View { let details: BankTransferDetails let onGetHelp: () -> Void let onBackToAccountNumber: () -> Void + let onChangePaymentMethod: () -> Void var body: some View { VStack(spacing: .triplePadding) { @@ -24,6 +25,10 @@ struct BankTransferTakingLongerView: View { Button("Back to account number", action: onBackToAccountNumber) .foregroundColor(.navy02) .font(.body14M) + + Button("Change payment method", action: onChangePaymentMethod) + .foregroundColor(.navy02) + .font(.body14M) } .padding(.doublePadding) } diff --git a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift index 1585a79..e761f4f 100644 --- a/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift +++ b/Sources/PaystackUI/Charge/BankTransfer/Views/BankTransferView.swift @@ -25,7 +25,8 @@ struct BankTransferView: View { details: details, amount: viewModel.transactionDetails.amountCurrency, provider: viewModel.config.provider, - onIveSentTheMoney: { await viewModel.userTappedIveSentTheMoney() }) + onIveSentTheMoney: { await viewModel.userTappedIveSentTheMoney() }, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) .id(details.transactionReference) case .confirmingPayment(let details, let phase): BankTransferConfirmingView( @@ -33,16 +34,18 @@ struct BankTransferView: View { phase: phase, confirmationWindowSeconds: viewModel.confirmationWindowSeconds, elapsedSeconds: viewModel.confirmationElapsedSeconds, - onBackToAccountNumber: viewModel.userTappedBackToAccountNumber) + onBackToAccountNumber: viewModel.userTappedBackToAccountNumber, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) case .takingLongerThanExpected(let details): BankTransferTakingLongerView( details: details, onGetHelp: viewModel.userTappedGetHelp, - onBackToAccountNumber: viewModel.userTappedBackToAccountNumber) + onBackToAccountNumber: viewModel.userTappedBackToAccountNumber, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod) case .delayedConfirmation: BankTransferDelayedConfirmationView( supportEmail: "support@paystack.com", - onClose: viewModel.userTappedCloseFromDelayedConfirmation, + onChangePaymentMethod: viewModel.userTappedChangePaymentMethod, onKeepWaiting: viewModel.userTappedKeepWaiting) case .refundInitiated(_, let message): BankTransferRefundInitiatedView( @@ -60,21 +63,7 @@ struct BankTransferView: View { error: error, transactionReference: viewModel.transactionDetails.reference)) } - - if showsChangePaymentMethodFooter { - ChangePaymentMethodFooter(action: viewModel.userTappedChangePaymentMethod) - } } .task(viewModel.provisionVirtualAccount) } - - private var showsChangePaymentMethodFooter: Bool { - switch viewModel.state { - case .awaitingPayment, .confirmingPayment, - .takingLongerThanExpected, .delayedConfirmation: - return true - case .loading, .error, .fatalError, .refundInitiated: - return false - } - } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift index 71d8375..5c541aa 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Viewmodels/MobileMoneyChargeViewModel.swift @@ -85,8 +85,9 @@ class MobileMoneyChargeViewModel: ObservableObject, @MainActor MobileMoneyContai transactionState = .error(error) } - func cancelTransaction() { - restartMobileMoneyPayment() + @MainActor + func userTappedChangePaymentMethod() { + chargeCardContainer.restartFromChannelSelection() } } diff --git a/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift index 5b51d93..bf05e5a 100644 --- a/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift +++ b/Sources/PaystackUI/Charge/MobileMoney/Views/MobileMoneyChargeView.swift @@ -45,7 +45,8 @@ struct MobileMoneyChargeView: View { FormInput(title: "Pay \(viewModel.transactionDetails.amountCurrency.description)", enabled: viewModel.isValid, action: viewModel.submitPhoneNumber, - secondaryAction: viewModel.cancelTransaction) { + secondaryButtonText: "Change payment method", + secondaryAction: viewModel.userTappedChangePaymentMethod) { phoneNumber } } diff --git a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift index 9f7d537..566bb1e 100644 --- a/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift +++ b/Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift @@ -84,7 +84,9 @@ struct ZapPaymentView: View { } Button("Change payment method", action: onChangePaymentMethod) - .buttonStyle(SecondaryButtonStyle()) + .foregroundColor(.navy02) + .font(.body14M) + .padding(.top, .singlePadding) } } diff --git a/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift index f7e5ec8..b87c735 100644 --- a/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/BankTransfer/BankTransferViewModelTests.swift @@ -321,10 +321,11 @@ final class BankTransferViewModelTests: XCTestCase { .takingLongerThanExpected(expectedDetails)) } - @MainActor func testUserTappedCloseFromDelayedConfirmationRestartsChannelSelection() { + @MainActor + func testUserTappedChangePaymentMethodFromDelayedConfirmationRestartsChannelSelection() { serviceUnderTest.state = .delayedConfirmation(.example) - serviceUnderTest.userTappedCloseFromDelayedConfirmation() + serviceUnderTest.userTappedChangePaymentMethod() XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) } diff --git a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift index 7616153..752c1fa 100644 --- a/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/MobileMoneyCharge/MobileMoneyChargeViewModelTests.swift @@ -232,10 +232,10 @@ final class MobileMoneyChargeViewModelTests: XCTestCase { XCTAssertEqual(serviceUnderTest.transactionState, .countdown) } - func testCancelTransactionRestartsPayment() { - serviceUnderTest.transactionState = .error(.generic) - serviceUnderTest.cancelTransaction() - XCTAssertEqual(serviceUnderTest.transactionState, .countdown) + @MainActor + func testUserTappedChangePaymentMethodRestartsChannelSelection() { + serviceUnderTest.userTappedChangePaymentMethod() + XCTAssertTrue(mockChargeContainer.channelSelectionRestarted) } } diff --git a/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift new file mode 100644 index 0000000..c370dbe --- /dev/null +++ b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import PaystackCore +@testable import PaystackUI + +final class ZapRepositoryImplementationTests: PSTestCase { + + let apiKey = "testsk_Example" + var serviceUnderTest: ZapRepositoryImplementation! + var paystack: Paystack! + + override func setUpWithError() throws { + try super.setUpWithError() + paystack = try PaystackBuilder.newInstance.setKey(apiKey).build() + PaystackContainer.instance.store(paystack) + serviceUnderTest = ZapRepositoryImplementation() + } + + // MARK: - initiateZapMandate + + /// End-to-end: builds the right URL on the Zap host, posts a + /// form-urlencoded body, decodes the response into `ZapMandateResponse`. + func testInitiateZapMandateSubmitsRequestUsingPaystackObjectAndMapsCorrectlyToModel() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .expectHeader("Content-Type", "application/x-www-form-urlencoded") + .andReturn(json: "ZapMandateResponse") + + let result = try await serviceUnderTest.initiateZapMandate( + supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + + XCTAssertEqual(result, .jsonExample) + } + + /// Regression for the `baseURL` override on `ZapMandateServiceImplementation`. + /// If the override regresses to the default `api.paystack.co`, this + /// expected URL stops matching and the test fails. + func testInitiateZapMandateHitsStandardHostNotApiHost() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ZapMandateResponse") + + _ = try await serviceUnderTest.initiateZapMandate( + supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + } + + /// Regression for the new `postForm(_:_:)` helper. If the service + /// regresses to JSON `post`, the Content-Type expectation fails. + func testInitiateZapMandateUsesFormUrlencodedContentType() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Content-Type", "application/x-www-form-urlencoded") + .andReturn(json: "ZapMandateResponse") + + _ = try await serviceUnderTest.initiateZapMandate( + supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + } + + func testInitiateZapMandateDecodesAllFieldsFromResponse() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579") + .expectMethod(.post) + .expectHeader("Authorization", "Bearer \(apiKey)") + .andReturn(json: "ZapMandateResponse") + + let result = try await serviceUnderTest.initiateZapMandate( + supportedBankId: 870, + transactionId: 6222375579, + walletEmail: "customer@example.com") + + XCTAssertEqual(result.status, "pending") + XCTAssertEqual(result.message, "Transaction Initiated") + XCTAssertEqual(result.pusherChannel, "DBMAN_6222375579") + XCTAssertEqual(result.paymentUrl, + "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu") + XCTAssertTrue(result.qrImage.hasPrefix("https://")) + } + + /// Each call should encode the `(supportedBankId, transactionId)` pair + /// into the path — a different pair produces a different URL. + func testInitiateZapMandateEmbedsSupportedBankIdAndTransactionIdInPath() async throws { + mockServiceExecutor + .expectURL("https://standard.paystack.co/bank/digitalbankmandate/42/9999") + .expectMethod(.post) + .andReturn(json: "ZapMandateResponse") + + _ = try await serviceUnderTest.initiateZapMandate( + supportedBankId: 42, + transactionId: 9999, + walletEmail: "x@example.com") + } + + // MARK: - listenForZapResponse + + /// Subscribes to the Pusher channel with the same `eventName: "response"` + /// contract as Pay-with-Transfer, and the success payload maps cleanly + /// to a `BankTransferTransactionUpdate` (shared wire format). + func testListenForZapResponseSubscribesToProvidedChannelAndMapsSuccess() async throws { + let channel = "DBMAN_6222375579" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) + + XCTAssertEqual(result.status, .success) + XCTAssertEqual(result.message, "Payment Successful") + XCTAssertEqual(result.transactionId, "3818017015") + XCTAssertEqual(result.reference, "T3818017015I615243Sujjxh") + } + + /// `failed` is the second of the two statuses Zap is documented to + /// emit (the other being `success`). Reuses the existing PWT + /// "failed-shape" fixture since Zap shares the wire format. + func testListenForZapResponseMapsFailedStatusFromSharedWireFormat() async throws { + let channel = "DBMAN_6222375579" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherIncorrectAmount") + + let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "incorrect amount sent") + } + + /// The channel name is passed through verbatim — the SDK doesn't + /// rewrite the `DBMAN_*` prefix or anything else. Mirror the PWT + /// repository's equivalent test so any future regression where the + /// channel name is mutated would fail loudly. + func testListenForZapResponsePassesChannelNameThroughVerbatim() async throws { + let channel = "DBMAN_arbitrary_123" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) + .andReturnString(fromJson: "PayWithTransferPusherSuccess") + + _ = try await serviceUnderTest.listenForZapResponse(onChannel: channel) + } +} + +// MARK: - Fixtures + +private extension ZapMandateResponse { + static var jsonExample: ZapMandateResponse { + ZapMandateResponse( + status: "pending", + message: "Transaction Initiated", + pusherChannel: "DBMAN_6222375579", + paymentUrl: "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu", + qrImage: "https://paystack-production-zap-eu-west-1.s3.eu-west-1.amazonaws.com/merchant-payments/qr/f3k3t3c88ovR6P7CDkKu/qr_f3k3t3c88ovR6P7CDkKu.png") + } +}