From 51bdaeb64941f5bad75ca8c5e09c0e09a95108fa Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Tue, 12 May 2026 14:38:45 +0200 Subject: [PATCH 1/6] Add redirect button configuration --- .../PONativeAlternativePaymentConfiguration.swift | 10 +++++++++- .../DefaultNativeAlternativePaymentViewModel.swift | 11 +++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift index b6484d087..0f4a62fcc 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift @@ -149,14 +149,22 @@ public struct PONativeAlternativePaymentConfiguration { /// and if it's the only required step, it will complete the flow without starting the bottom sheet. public let enableHeadlessMode: Bool + /// Redirect confirmation button configuration. To remove button use `nil`, this is default behaviour. + /// + /// Displays a confirmation button when the user needs to perform a redirect. The user + /// must press this button to continue. + public let redirectButton: SubmitButton? + public init( callback: POWebAuthenticationCallback? = nil, prefersEphemeralSession: Bool = true, - enableHeadlessMode: Bool = false + enableHeadlessMode: Bool = false, + redirectButton: SubmitButton? = nil ) { self.callback = callback self.prefersEphemeralSession = prefersEphemeralSession self.enableHeadlessMode = enableHeadlessMode + self.redirectButton = redirectButton } } diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift index 3d4b0a51b..991961b02 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift @@ -305,7 +305,7 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { ) -> NativeAlternativePaymentViewModelControlGroup? { var buttons: [POButtonViewModel] = [ createRedirectButton(state: state, isRedirecting: isRedirecting) - ] + ].compactMap(\.self) let cancelButton = createCancelButton( configuration: interactor.configuration.cancelButton, isEnabled: state.isCancellable ) @@ -324,11 +324,14 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { private func createRedirectButton( state: InteractorState.AwaitingRedirect, isRedirecting: Bool - ) -> POButtonViewModel { - // todo(andrii-vysotskyi): decide whether button should be customizable + ) -> POButtonViewModel? { + guard let buttonConfiguration = interactor.configuration.redirect.redirectButton else { + return nil + } let viewModel = POButtonViewModel( id: "redirect-button", - title: state.redirect.hint, + title: buttonConfiguration.title ?? state.redirect.hint, + icon: buttonConfiguration.icon, isEnabled: true, isLoading: isRedirecting, role: .primary, From 428fc76d0fe62fd8d310579df2f94bf07311f8a2 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Fri, 24 Apr 2026 16:06:00 +0200 Subject: [PATCH 2/6] Refactor interactor --- ...eAlternativePaymentDefaultInteractor.swift | 98 ++++++++----------- 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index d15dcc90c..b6e41ff41 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -142,29 +142,7 @@ final class NativeAlternativePaymentDefaultInteractor: } let task = Task { do { - let didOpenUrl: Bool - switch currentState.redirect.type { - case .deepLink: - didOpenUrl = await openDeepLink(url: currentState.redirect.url) - case .web: - let authenticationRequest = POAlternativePaymentAuthenticationRequest( - url: currentState.redirect.url, - callback: configuration.redirect.callback, - prefersEphemeralSession: configuration.redirect.prefersEphemeralSession - ) - _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) - didOpenUrl = true - default: - throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) - } - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: currentState.redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) + try await uncheckedRedirect(to: currentState.redirect) } catch { setFailureState(error: error) } @@ -237,29 +215,7 @@ final class NativeAlternativePaymentDefaultInteractor: logger.error("Attempted to handle headless redirect while not in starting state. Ignoring.") return } - let didOpenUrl: Bool - switch redirect.type { - case .deepLink: - didOpenUrl = await openDeepLink(url: redirect.url) - case .web: - let authenticationRequest = POAlternativePaymentAuthenticationRequest( - url: redirect.url, - callback: configuration.redirect.callback, - prefersEphemeralSession: configuration.redirect.prefersEphemeralSession - ) - _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) - didOpenUrl = true - default: - throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) - } - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) + try await uncheckedRedirect(to: redirect) } // MARK: - Started State @@ -326,6 +282,44 @@ final class NativeAlternativePaymentDefaultInteractor: private var cancelationEnablingTask: Task? + // MARK: - Redirecting State + + private func uncheckedRedirect(to redirect: PONativeAlternativePaymentRedirectV2) async throws { + let didOpenUrl: Bool + switch redirect.type { + case .deepLink: + didOpenUrl = await openDeepLink(url: redirect.url) + case .web: + let authenticationRequest = POAlternativePaymentAuthenticationRequest( + url: redirect.url, + callback: configuration.redirect.callback, + prefersEphemeralSession: configuration.redirect.prefersEphemeralSession + ) + _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) + didOpenUrl = true + default: + throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) + } + let response = try await serviceAdapter.continuePayment( + with: .init( + flow: configuration.flow, + redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, + localeIdentifier: configuration.localization.localeOverride?.identifier + ) + ) + try await setState(with: response) + } + + private func openDeepLink(url: URL) async -> Bool { + let options: [UIApplication.OpenExternalURLOptionsKey: Any] + if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal + options = [.universalLinksOnly: true] + } else { + options = [:] + } + return await UIApplication.shared.open(url, options: options) + } + // MARK: - Awaiting Completion State private func setAwaitingCompletionState(response: NativeAlternativePaymentServiceAdapterResponse) async { @@ -935,18 +929,6 @@ final class NativeAlternativePaymentDefaultInteractor: return nil } - // MARK: - Redirect Utils - - private func openDeepLink(url: URL) async -> Bool { - let options: [UIApplication.OpenExternalURLOptionsKey: Any] - if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal - options = [.universalLinksOnly: true] - } else { - options = [:] - } - return await UIApplication.shared.open(url, options: options) - } - // MARK: - External Events private func observeEvents() { From 2a837fe5fc5299a85c2c0a8220e458c38ca62147 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 13 May 2026 16:17:32 +0200 Subject: [PATCH 3/6] Support unconditional redirect --- ...eAlternativePaymentDefaultInteractor.swift | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index b6e41ff41..9f01a63f8 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -395,25 +395,29 @@ final class NativeAlternativePaymentDefaultInteractor: response: NativeAlternativePaymentServiceAdapterResponse, redirect: PONativeAlternativePaymentRedirectV2 ) async throws { - let paymentMethod = await resolve(paymentMethod: response.paymentMethod) - let elements = try await resolve(elements: response.elements ?? []) - switch state { - case .starting, .submitting, .redirecting: - break // todo(andrii-vysotskyi): check if more states should be supported - default: - logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") - return + if configuration.redirect.redirectButton != nil { + let paymentMethod = await resolve(paymentMethod: response.paymentMethod) + let elements = try await resolve(elements: response.elements ?? []) + switch state { + case .starting, .submitting, .redirecting: + break // todo(andrii-vysotskyi): check if more states should be supported + default: + logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") + return + } + let newState = State.AwaitingRedirect( + paymentMethod: paymentMethod, + invoice: response.invoice, + elements: elements, + redirect: redirect, + isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true + ) + sendDidStartEventIfNeeded() + state = .awaitingRedirect(newState) + enableCancellationAfterDelay() + } else { + try await uncheckedRedirect(to: redirect) } - let newState = State.AwaitingRedirect( - paymentMethod: paymentMethod, - invoice: response.invoice, - elements: elements, - redirect: redirect, - isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true - ) - sendDidStartEventIfNeeded() - state = .awaitingRedirect(newState) - enableCancellationAfterDelay() } /// Requests state change to redirecting while bypassing actual redirect assuming it was performed elsewhere. From e079b5714c1cecdc131f2186ec369c21b6f44b65 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 13 May 2026 16:25:10 +0200 Subject: [PATCH 4/6] Remove redundant state --- ...eAlternativePaymentDefaultInteractor.swift | 117 +++++++----------- 1 file changed, 45 insertions(+), 72 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index 9f01a63f8..7f40f288e 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -190,9 +190,7 @@ final class NativeAlternativePaymentDefaultInteractor: private func setState(with response: NativeAlternativePaymentServiceAdapterResponse) async throws { switch response.state { case .nextStepRequired: - if case .starting = state, let redirect = response.redirect, configuration.redirect.enableHeadlessMode { - try await continueStart(withHeadlessRedirect: redirect) - } else if let redirect = response.redirect { + if let redirect = response.redirect { try await setAwaitingRedirectState(response: response, redirect: redirect) } else { try await setStartedState(response: response) @@ -208,16 +206,6 @@ final class NativeAlternativePaymentDefaultInteractor: } } - // MARK: - Starting State - - private func continueStart(withHeadlessRedirect redirect: PONativeAlternativePaymentRedirectV2) async throws { - guard case .starting = state else { - logger.error("Attempted to handle headless redirect while not in starting state. Ignoring.") - return - } - try await uncheckedRedirect(to: redirect) - } - // MARK: - Started State private func setStartedState(response: NativeAlternativePaymentServiceAdapterResponse) async throws { @@ -282,44 +270,6 @@ final class NativeAlternativePaymentDefaultInteractor: private var cancelationEnablingTask: Task? - // MARK: - Redirecting State - - private func uncheckedRedirect(to redirect: PONativeAlternativePaymentRedirectV2) async throws { - let didOpenUrl: Bool - switch redirect.type { - case .deepLink: - didOpenUrl = await openDeepLink(url: redirect.url) - case .web: - let authenticationRequest = POAlternativePaymentAuthenticationRequest( - url: redirect.url, - callback: configuration.redirect.callback, - prefersEphemeralSession: configuration.redirect.prefersEphemeralSession - ) - _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) - didOpenUrl = true - default: - throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) - } - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) - } - - private func openDeepLink(url: URL) async -> Bool { - let options: [UIApplication.OpenExternalURLOptionsKey: Any] - if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal - options = [.universalLinksOnly: true] - } else { - options = [:] - } - return await UIApplication.shared.open(url, options: options) - } - // MARK: - Awaiting Completion State private func setAwaitingCompletionState(response: NativeAlternativePaymentServiceAdapterResponse) async { @@ -395,7 +345,7 @@ final class NativeAlternativePaymentDefaultInteractor: response: NativeAlternativePaymentServiceAdapterResponse, redirect: PONativeAlternativePaymentRedirectV2 ) async throws { - if configuration.redirect.redirectButton != nil { + if shouldConfirmRedirect(redirect: redirect, in: state) { let paymentMethod = await resolve(paymentMethod: response.paymentMethod) let elements = try await resolve(elements: response.elements ?? []) switch state { @@ -420,28 +370,51 @@ final class NativeAlternativePaymentDefaultInteractor: } } - /// Requests state change to redirecting while bypassing actual redirect assuming it was performed elsewhere. - private func setRedirectingState(didOpenUrl: Bool) { - guard case .awaitingRedirect(let currentState) = state else { - logger.debug("Ignoring redirect confirmation in unsupported state \(state).") - return + private func shouldConfirmRedirect( + redirect: PONativeAlternativePaymentRedirectV2, in state: NativeAlternativePaymentInteractorState + ) -> Bool { + if case .starting = state, configuration.redirect.enableHeadlessMode { + return false } - let task = Task { - do { - let response = try await serviceAdapter.continuePayment( - with: .init( - flow: configuration.flow, - redirect: currentState.redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, - localeIdentifier: configuration.localization.localeOverride?.identifier - ) - ) - try await setState(with: response) - } catch { - setFailureState(error: error) - } + return configuration.redirect.redirectButton != nil + } + + // MARK: - Redirecting State + + private func uncheckedRedirect(to redirect: PONativeAlternativePaymentRedirectV2) async throws { + let didOpenUrl: Bool + switch redirect.type { + case .deepLink: + didOpenUrl = await openDeepLink(url: redirect.url) + case .web: + let authenticationRequest = POAlternativePaymentAuthenticationRequest( + url: redirect.url, + callback: configuration.redirect.callback, + prefersEphemeralSession: configuration.redirect.prefersEphemeralSession + ) + _ = try await alternativePaymentsService.authenticate(request: authenticationRequest) + didOpenUrl = true + default: + throw POFailure(errorDescription: "Unknown redirect type.", code: .Mobile.internal) } - let newState = State.Redirecting(task: task, snapshot: currentState) - state = .redirecting(newState) + let response = try await serviceAdapter.continuePayment( + with: .init( + flow: configuration.flow, + redirect: redirect.confirmationRequired ? .init(success: didOpenUrl) : nil, + localeIdentifier: configuration.localization.localeOverride?.identifier + ) + ) + try await setState(with: response) + } + + private func openDeepLink(url: URL) async -> Bool { + let options: [UIApplication.OpenExternalURLOptionsKey: Any] + if url.scheme == "https" || url.scheme == "http" { // Determines whether link could be universal + options = [.universalLinksOnly: true] + } else { + options = [:] + } + return await UIApplication.shared.open(url, options: options) } // MARK: - Completed State From 43ea1aa146575ac5f125ecc971db8b53eaa9c7c5 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 13 May 2026 16:30:14 +0200 Subject: [PATCH 5/6] Disable fallback redirect confirmation --- .../NativeAlternativePaymentDefaultInteractor.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index 7f40f288e..a19a40d74 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -373,10 +373,14 @@ final class NativeAlternativePaymentDefaultInteractor: private func shouldConfirmRedirect( redirect: PONativeAlternativePaymentRedirectV2, in state: NativeAlternativePaymentInteractorState ) -> Bool { - if case .starting = state, configuration.redirect.enableHeadlessMode { + switch state { + case .redirecting: + return false + case .starting where configuration.redirect.enableHeadlessMode: return false + default: + return configuration.redirect.redirectButton != nil } - return configuration.redirect.redirectButton != nil } // MARK: - Redirecting State From 179c1712811d80d298429503a6f1d8e1d65236f6 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 13 May 2026 16:33:41 +0200 Subject: [PATCH 6/6] Update naming --- .../NativeAlternativePaymentDefaultInteractor.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index a19a40d74..4bef2da20 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -345,7 +345,7 @@ final class NativeAlternativePaymentDefaultInteractor: response: NativeAlternativePaymentServiceAdapterResponse, redirect: PONativeAlternativePaymentRedirectV2 ) async throws { - if shouldConfirmRedirect(redirect: redirect, in: state) { + if shouldConfirmRedirect(redirect, in: state) { let paymentMethod = await resolve(paymentMethod: response.paymentMethod) let elements = try await resolve(elements: response.elements ?? []) switch state { @@ -371,7 +371,7 @@ final class NativeAlternativePaymentDefaultInteractor: } private func shouldConfirmRedirect( - redirect: PONativeAlternativePaymentRedirectV2, in state: NativeAlternativePaymentInteractorState + _ redirect: PONativeAlternativePaymentRedirectV2, in state: NativeAlternativePaymentInteractorState ) -> Bool { switch state { case .redirecting: