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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,27 @@ 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(
details: details,
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(
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ class MobileMoneyChargeViewModel: ObservableObject, @MainActor MobileMoneyContai
transactionState = .error(error)
}

func cancelTransaction() {
restartMobileMoneyPayment()
@MainActor
func userTappedChangePaymentMethod() {
chargeCardContainer.restartFromChannelSelection()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/PaystackUI/Charge/Zap/Views/ZapPaymentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ struct ZapPaymentView: View {
}

Button("Change payment method", action: onChangePaymentMethod)
.buttonStyle(SecondaryButtonStyle())
.foregroundColor(.navy02)
.font(.body14M)
.padding(.top, .singlePadding)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading