From 1de5be1b9f25c414f80920a0b056f1ca29884430 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 14:27:51 +0100 Subject: [PATCH 1/9] docs(ad-hoc): clarify native alternative payment headless mode behaviour --- .../PONativeAlternativePaymentConfiguration.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift index 85e79ad84..a66e76eb8 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfiguration.swift @@ -145,8 +145,19 @@ public struct PONativeAlternativePaymentConfiguration { /// Enables headless mode. /// - /// The web redirect will be handled directly when it's the first step in the flow, - /// and if it's the only required step, it will complete the flow without starting the bottom sheet. + /// When enabled, a redirect returned as the first step of the flow is performed immediately, + /// skipping the confirmation configured with ``redirectButton``. Redirects required at later + /// steps (for example, after parameters are submitted) still respect ``redirectButton``. + /// + /// - Note: When ``redirectButton`` is `nil` (the default), redirects never require confirmation, + /// so this option has no effect. + /// + /// - Note: For web redirects the flow waits for the customer to return, so if the redirect is + /// the only required step the payment may complete before the component's `start()` returns, + /// without presenting the component's own UI. The redirect itself is still presented in a system + /// web authentication session, which requires a foreground-active window. For deep link redirects + /// the flow continues as soon as the link is opened, so `start()` usually returns while the payment + /// is still pending. public let enableHeadlessMode: Bool /// Redirect confirmation button configuration. To remove button use `nil`, this is default behaviour. From a628e116a2773fc175bb26f69c764fb9d6cd2443 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 16:49:10 +0100 Subject: [PATCH 2/9] feat(ad-hoc): support metadata when creating invoice --- .../Invoices/Requests/POInvoiceCreationRequest.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift index e775e7f9f..18fcaea77 100644 --- a/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift +++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift @@ -50,13 +50,17 @@ public struct POInvoiceCreationRequest: Encodable, Sendable { /// Invoice details. public let details: [Detail] + /// Additional metadata attached to the invoice. + public let metadata: [String: String]? + public init( name: String, amount: Decimal, currency: String, returnUrl: URL? = nil, customerId: String? = nil, - details: [Detail] = [] + details: [Detail] = [], + metadata: [String: String]? = nil ) { self.name = name self._amount = .init(value: amount) @@ -64,5 +68,6 @@ public struct POInvoiceCreationRequest: Encodable, Sendable { self.returnUrl = returnUrl self.customerId = customerId self.details = details + self.metadata = metadata } } From eae1990f403189fb1add71e0c9d7ada1a5238ec0 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 16:55:09 +0100 Subject: [PATCH 3/9] feat(ad-hoc): add task cancellation check helper --- .../Core/Utils/Task+Cancellation.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift diff --git a/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift b/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift new file mode 100644 index 000000000..a8b6b04d3 --- /dev/null +++ b/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift @@ -0,0 +1,22 @@ +// +// Task+Cancellation.swift +// ProcessOut +// +// Created by Andrii Vysotskyi on 28.09.2026. +// + +extension Task where Success == Never, Failure == Never { + + /// Throws cancellation failure if the current task has been cancelled. + @_spi(PO) + public static func poCheckCancellation() throws(POFailure) { + guard Task.isCancelled else { + return + } + throw POFailure( + message: "Task was cancelled.", + code: .Mobile.cancelled, + underlyingError: Swift.CancellationError() + ) + } +} From 7ee017763ce6f4f440880134d30cf278d26af5b0 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 16:57:27 +0100 Subject: [PATCH 4/9] feat(ad-hoc): add unexpected redirect result failure codes --- .../Shared/Responses/Failure/POFailureCode.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift index 156068d55..6f99efa5d 100644 --- a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift +++ b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift @@ -368,6 +368,16 @@ extension POFailureCode { /// The redirect result is not support and can't be handled. public static let redirectResultInvalid = POFailureCode(rawValue: "request.validation.redirect-result-invalid") + + /// The redirect result is not expected, for example because payment has already advanced past the redirect. + public static let redirectResultUnexpected = POFailureCode( + rawValue: "request.validation.redirect-result-unexpected" + ) + + /// The redirect result is not expected for the step payment is currently at. + public static let redirectResultUnexpectedForStep = POFailureCode( + rawValue: "request.validation.redirect-result-unexpected-for-step" + ) } public enum Request { From 552f1827050e82f8f3643635f063d7dc8079ae8c Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 17:00:14 +0100 Subject: [PATCH 5/9] fix(ad-hoc): poll native alternative payment only while it is pending --- .../Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift index 59f37e761..509d9577e 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift @@ -63,7 +63,7 @@ final class DefaultNativeAlternativePaymentServiceAdapter: NativeAlternativePaym while: { result in switch result { case let .success(response): - return response.state != .success + return response.state == .pending // Any other state is handled by caller case let .failure(failure as POFailure): let retriableCodes: [POFailureCode] = [ .Mobile.networkUnreachable, .Mobile.timeout, .Mobile.internal From 4525e5172de688d9db552de7474195b3839f0c05 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Wed, 11 Nov 2026 17:18:03 +0100 Subject: [PATCH 6/9] fix(ad-hoc): improve native alternative payment state and failure handling --- ...eAlternativePaymentDefaultInteractor.swift | 195 ++++++++++-------- ...iveAlternativePaymentInteractorState.swift | 31 +++ 2 files changed, 138 insertions(+), 88 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index e137686fb..6f2c0263f 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -59,9 +59,9 @@ final class NativeAlternativePaymentDefaultInteractor: flow: configuration.flow, localeIdentifier: configuration.localization.localeOverride?.identifier ) let payment = try await serviceAdapter.continuePayment(with: request) - try await setState(with: payment) + await setState(with: payment) } catch { - setFailureState(error: error) + setFailureState(error: error, paymentState: nil) } } state = .starting(.init(task: task)) @@ -97,6 +97,7 @@ final class NativeAlternativePaymentDefaultInteractor: } willSubmit(parameters: Array(currentState.parameters.values)) let task = Task { @MainActor in + let payment: NativeAlternativePaymentServiceAdapterResponse do { let values = try validatedValues(for: Array(currentState.parameters.values)) let request = NativeAlternativePaymentServiceAdapterRequest( @@ -104,29 +105,28 @@ final class NativeAlternativePaymentDefaultInteractor: submitData: .init(parameters: values), localeIdentifier: configuration.localization.localeOverride?.identifier ) - let payment = try await serviceAdapter.continuePayment(with: request) - let submittedParametersSpecifications = Array(currentState.parameters.values.map(\.specification)) - switch payment.state { - case .nextStepRequired: - send( - event: .didSubmitParameters( - .init(parameters: submittedParametersSpecifications, additionalParametersExpected: true) - ) - ) - logger.debug("More parameters are expected, waiting for parameters to update.") - case .success, .pending: - send( - event: .didSubmitParameters( - .init(parameters: submittedParametersSpecifications, additionalParametersExpected: false) - ) - ) - default: - setFailureState(error: POFailure(message: "Unexpected payment state.", code: .Mobile.generic)) - } - try await setState(with: payment) + payment = try await serviceAdapter.continuePayment(with: request) } catch { attemptRecoverSubmissionError(error) + return } + guard !Task.isCancelled else { + logger.debug("Submission was cancelled, ignoring response.") + return + } + let submittedParametersSpecifications = Array(currentState.parameters.values.map(\.specification)) + switch payment.state { + case .nextStepRequired: + send(event: .didSubmitParameters( + .init(parameters: submittedParametersSpecifications, additionalParametersExpected: true) + )) + logger.debug("More parameters are expected, waiting for parameters to update.") + default: + send(event: .didSubmitParameters( + .init(parameters: submittedParametersSpecifications, additionalParametersExpected: false) + )) + } + await setState(with: payment) } state = .submitting(.init(snapshot: currentState, task: task)) } @@ -144,7 +144,7 @@ final class NativeAlternativePaymentDefaultInteractor: do { try await uncheckedRedirect(to: currentState.redirect) } catch { - setFailureState(error: error) + setFailureState(error: error, paymentState: currentState.paymentState) } } let newState = State.Redirecting(task: task, snapshot: currentState) @@ -173,14 +173,16 @@ final class NativeAlternativePaymentDefaultInteractor: // Intent here is not to cancel invocation of completion but to fast-forward // it by cancelling any ongoing delay operation if any. currentState.completionTask.cancel() + return default: break } - setFailureState( + setFailureStateUnchecked( error: POFailure( message: "Alternative payment has been canceled. Reason: '\(reason)'.", code: .Mobile.cancelled - ) + ), + paymentState: nil ) } @@ -196,22 +198,28 @@ final class NativeAlternativePaymentDefaultInteractor: // MARK: - State Handling - private func setState(with response: NativeAlternativePaymentServiceAdapterResponse) async throws { - switch response.state { - case .nextStepRequired: - if let redirect = response.redirect { - try await setAwaitingRedirectState(response: response, redirect: redirect) - } else { - try await setStartedState(response: response) + /// Applies given payment response to the current state. + /// + /// - NOTE: Failures that happen while response is being applied are reported with response's payment state. + private func setState(with response: NativeAlternativePaymentServiceAdapterResponse) async { + do { + switch response.state { + case .nextStepRequired: + if let redirect = response.redirect { + try await setAwaitingRedirectState(response: response, redirect: redirect) + } else { + try await setStartedState(response: response) + } + case .pending: + await setAwaitingCompletionState(response: response) + case .success: + await setCompletedState(response: response) + default: + logger.error("Unexpected alternative payment state: \(response.state).") + throw POFailure(message: "Something went wrong.", code: .Mobile.generic) } - case .pending: - await setAwaitingCompletionState(response: response) - case .success: - await setCompletedState(response: response) - default: - logger.error("Unexpected alternative payment state: \(response.state).") - let failure = POFailure(message: "Something went wrong.", code: .Mobile.generic) - setFailureState(error: failure) + } catch { + setFailureState(error: error, paymentState: response.state) } } @@ -221,9 +229,10 @@ final class NativeAlternativePaymentDefaultInteractor: let elements = try await resolve(elements: response.elements ?? []) let paymentMethod = await resolve(paymentMethod: response.paymentMethod) let parameters = await createParameters(for: response.elements ?? []) + try Task.poCheckCancellation() switch state { - case .starting, .submitting, .redirecting: - break // todo(andrii-vysotskyi): check if more states should be supported + case .starting, .submitting, .redirecting, .awaitingCompletion: + break // Payment may require another step while it is awaited to complete default: logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") return @@ -234,6 +243,7 @@ final class NativeAlternativePaymentDefaultInteractor: let startedState = State.Started( paymentMethod: paymentMethod, invoice: response.invoice, + paymentState: response.state, elements: elements, parameters: parameters, isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true @@ -284,11 +294,13 @@ final class NativeAlternativePaymentDefaultInteractor: private func setAwaitingCompletionState(response: NativeAlternativePaymentServiceAdapterResponse) async { if case .awaitingCompletion(let currentState) = state, !currentState.shouldConfirmPayment { let failure = POFailure(code: .Mobile.generic) - setFailureState(error: failure) + setFailureState(error: failure, paymentState: response.state) return } do { let resolvedElements = try await resolve(elements: response.elements ?? []) + let paymentMethod = await resolve(paymentMethod: response.paymentMethod) + try Task.poCheckCancellation() switch state { case .starting, .submitting, .redirecting: break @@ -301,8 +313,9 @@ final class NativeAlternativePaymentDefaultInteractor: let shouldConfirmPayment = !resolvedElements.isEmpty && configuration.paymentConfirmation.confirmButton != nil let awaitingPaymentCompletionState = State.AwaitingCompletion( - paymentMethod: await resolve(paymentMethod: response.paymentMethod), + paymentMethod: paymentMethod, invoice: response.invoice, + paymentState: response.state, elements: resolvedElements, estimatedCompletionDate: nil, isCancellable: configuration.paymentConfirmation.cancelButton?.disabledFor.isZero ?? true, @@ -314,7 +327,7 @@ final class NativeAlternativePaymentDefaultInteractor: } enablePaymentConfirmationCancellationAfterDelay() } catch { - setFailureState(error: error) + setFailureState(error: error, paymentState: response.state) } } @@ -337,9 +350,9 @@ final class NativeAlternativePaymentDefaultInteractor: flow: configuration.flow, localeIdentifier: configuration.localization.localeOverride?.identifier ) let response = try await serviceAdapter.expectPaymentCompletion(with: request) - try await setState(with: response) + await setState(with: response) } catch { - setFailureState(error: error) + setFailureState(error: error, paymentState: currentState.paymentState) } } newState.estimatedCompletionDate = Date().addingTimeInterval(configuration.paymentConfirmation.timeout) @@ -357,16 +370,18 @@ final class NativeAlternativePaymentDefaultInteractor: if shouldConfirmRedirect(redirect, in: state) { let paymentMethod = await resolve(paymentMethod: response.paymentMethod) let elements = try await resolve(elements: response.elements ?? []) + try Task.poCheckCancellation() switch state { - case .starting, .submitting, .redirecting: - break // todo(andrii-vysotskyi): check if more states should be supported + case .starting, .submitting, .redirecting, .awaitingCompletion: + break // Payment may require another step while it is awaited to complete default: - logger.debug("Ignoring attempt to set started state in unsupported state: \(state).") + logger.debug("Ignoring attempt to set awaiting redirect state in unsupported state: \(state).") return } let newState = State.AwaitingRedirect( paymentMethod: paymentMethod, invoice: response.invoice, + paymentState: response.state, elements: elements, redirect: redirect, isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true @@ -393,6 +408,7 @@ final class NativeAlternativePaymentDefaultInteractor: } private func uncheckedRedirect(to redirect: PONativeAlternativePaymentRedirectV2) async throws { + try Task.poCheckCancellation() // Redirect can't be reverted once started delegate?.nativeAlternativePayment( didEmitEvent: .willStartRedirect(.init(redirect: redirect)) ) @@ -419,7 +435,7 @@ final class NativeAlternativePaymentDefaultInteractor: localeIdentifier: configuration.localization.localeOverride?.identifier ) ) - try await setState(with: response) + await setState(with: response) } private func openDeepLink(url: URL) async -> Bool { @@ -437,6 +453,8 @@ final class NativeAlternativePaymentDefaultInteractor: private func setCompletedState(response: NativeAlternativePaymentServiceAdapterResponse) async { do { let resolvedElements = try await resolve(elements: response.elements ?? []) + let paymentMethod = await resolve(paymentMethod: response.paymentMethod) + try Task.poCheckCancellation() guard !state.isSink else { logger.debug("Already in a sink state, ignoring attempt to set completed state.") return @@ -453,8 +471,9 @@ final class NativeAlternativePaymentDefaultInteractor: } } let newState = State.Completed( - paymentMethod: await resolve(paymentMethod: response.paymentMethod), + paymentMethod: paymentMethod, invoice: response.invoice, + paymentState: response.state, elements: resolvedElements, completionTask: task ) @@ -464,18 +483,18 @@ final class NativeAlternativePaymentDefaultInteractor: completion(.success(())) } } catch { - setFailureState(error: error) + setFailureState(error: error, paymentState: response.state) } } // MARK: - Submission Recovery private func attemptRecoverSubmissionError(_ error: Error) { - logger.info("Did fail to submit parameters: \(error)") - guard let failure = error as? POFailure else { - setFailureState(error: error) + guard !Task.isCancelled else { + logger.debug("Submission was cancelled, ignoring error: \(error).") return } + logger.info("Did fail to submit parameters: \(error)") var newState: State.Started switch state { case let .started(state): @@ -486,12 +505,16 @@ final class NativeAlternativePaymentDefaultInteractor: logger.debug("Ignoring attempt to recover submission error from unsupported state: \(state).") return } + guard let failure = error as? POFailure else { + setFailureState(error: error, paymentState: newState.paymentState) + return + } let invalidFields = failure.invalidFields.map { invalidFields in Dictionary(grouping: invalidFields, by: \.name).compactMapValues(\.first) } guard let invalidFields = invalidFields, !invalidFields.isEmpty else { logger.debug("Submission error is not recoverable, aborting.") - setFailureState(error: failure) + setFailureState(error: failure, paymentState: newState.paymentState) return } for parameter in newState.parameters.values { @@ -505,11 +528,17 @@ final class NativeAlternativePaymentDefaultInteractor: // MARK: - Failure State - private func setFailureState(error: Error) { + private func setFailureState(error: Error, paymentState: PONativeAlternativePaymentStateV2?) { guard !Task.isCancelled else { logger.debug("Task is cancelled, ignoring attempt to set failure state with: \(error).") return } + setFailureStateUnchecked(error: error, paymentState: paymentState) + } + + /// Sets failure state regardless of whether current task is cancelled. Should be used for explicit requests, + /// like cancellation, whose outcome must not depend on caller's context. + private func setFailureStateUnchecked(error: Error, paymentState: PONativeAlternativePaymentStateV2?) { guard !state.isSink else { logger.debug("Already in a sink state, ignoring attempt to set failure state with: \(error).") return @@ -522,28 +551,15 @@ final class NativeAlternativePaymentDefaultInteractor: logger.error("Unexpected error type: \(error)") failure = POFailure(message: "Something went wrong.", code: .Mobile.generic, underlyingError: error) } + let resolvedPaymentState = paymentState ?? state.paymentState state = .failure(failure) let failureEvent = PONativeAlternativePaymentEventV2.DidFail( - failure: failure, paymentState: currentPaymentState + failure: failure, paymentState: resolvedPaymentState ) send(event: .didFail(failureEvent)) completion(.failure(failure)) } - /// Payment state resolve from current interactor's state. - private var currentPaymentState: PONativeAlternativePaymentStateV2? { - switch state { - case .idle, .starting, .failure: - return nil - case .started, .submitting, .awaitingRedirect, .redirecting: - return .nextStepRequired - case .awaitingCompletion: - return .pending - case .completed: - return .success - } - } - // MARK: - Cancellation Availability // swiftlint:disable:next identifier_name @@ -984,19 +1000,14 @@ final class NativeAlternativePaymentDefaultInteractor: redirect: response.redirect ) Task { @MainActor in - do { - switch state { - case .idle, .started, .awaitingRedirect: - try await setState(with: adapterResponse) - case .awaitingCompletion(let currentState) where adapterResponse.state != .pending: - currentState.task?.cancel() - try await setState(with: adapterResponse) - default: - logger.info("Unable to apply resolve deep link to current state: \(state).") - return - } - } catch { - setFailureState(error: error) + switch state { + case .idle, .started, .awaitingRedirect: + await setState(with: adapterResponse) + case .awaitingCompletion(let currentState) where adapterResponse.state != .pending: + currentState.task?.cancel() + await setState(with: adapterResponse) + default: + logger.info("Unable to apply resolve deep link to current state: \(state).") } } } @@ -1009,7 +1020,15 @@ final class NativeAlternativePaymentDefaultInteractor: logger.error("Unexpected error type: \(error)") failure = .init(message: "Unable to resolve deep link URL.", code: .Mobile.generic, underlyingError: error) } - guard failure.failureCode != .RequestValidation.redirectResultInvalid else { + // Redirect result is either unrelated to this payment or no longer needed, so payment continues as is. + // When payment has advanced to another step, polling picks it up because it stops once payment + // is no longer pending. + let ignoredFailureCodes: [POFailureCode] = [ + .RequestValidation.redirectResultInvalid, + .RequestValidation.redirectResultUnexpected, + .RequestValidation.redirectResultUnexpectedForStep + ] + guard !ignoredFailureCodes.contains(failure.failureCode) else { return } switch state { @@ -1026,7 +1045,7 @@ final class NativeAlternativePaymentDefaultInteractor: default: break } - setFailureState(error: failure) + setFailureState(error: failure, paymentState: nil) } private var eventListeners: [AnyObject] = [] diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractorState.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractorState.swift index ca3740465..400666750 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractorState.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractorState.swift @@ -26,6 +26,9 @@ enum NativeAlternativePaymentInteractorState { /// Invoice information if any. let invoice: PONativeAlternativePaymentInvoiceV2? + /// Payment state. + let paymentState: PONativeAlternativePaymentStateV2 + /// Elements. var elements: [NativeAlternativePaymentResolvedElement] @@ -53,6 +56,9 @@ enum NativeAlternativePaymentInteractorState { /// Invoice information if any. let invoice: PONativeAlternativePaymentInvoiceV2? + /// Payment state. + let paymentState: PONativeAlternativePaymentStateV2 + /// Resolved elements. let elements: [NativeAlternativePaymentResolvedElement] @@ -80,6 +86,9 @@ enum NativeAlternativePaymentInteractorState { /// Invoice information if any. let invoice: PONativeAlternativePaymentInvoiceV2? + /// Payment state. + let paymentState: PONativeAlternativePaymentStateV2 + /// Resolved elements. let elements: [NativeAlternativePaymentResolvedElement] @@ -105,6 +114,9 @@ enum NativeAlternativePaymentInteractorState { /// Invoice information if any. let invoice: PONativeAlternativePaymentInvoiceV2? + /// Payment state. + let paymentState: PONativeAlternativePaymentStateV2 + /// Resolved elements. let elements: [NativeAlternativePaymentResolvedElement] @@ -176,6 +188,25 @@ extension NativeAlternativePaymentInteractorState: InteractorState { } } + var paymentState: PONativeAlternativePaymentStateV2? { + switch self { + case .idle, .starting, .failure: + nil + case .started(let state): + state.paymentState + case .submitting(let state): + state.snapshot.paymentState + case .awaitingRedirect(let state): + state.paymentState + case .redirecting(let state): + state.snapshot.paymentState + case .awaitingCompletion(let state): + state.paymentState + case .completed(let state): + state.paymentState + } + } + var isSink: Bool { switch self { case .completed, .failure: From 40c764708dc3685a1f1d118ced1c724bc36cef4f Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Thu, 12 Nov 2026 16:40:25 +0100 Subject: [PATCH 7/9] fix(ad-hoc): resolve cancellation error from concurrency module --- Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift b/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift index a8b6b04d3..48a9dd503 100644 --- a/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift +++ b/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift @@ -16,7 +16,7 @@ extension Task where Success == Never, Failure == Never { throw POFailure( message: "Task was cancelled.", code: .Mobile.cancelled, - underlyingError: Swift.CancellationError() + underlyingError: _Concurrency.CancellationError() ) } } From 4098ee6fb5c813cb992cb0ce7003e75b96a60e6a Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Thu, 12 Nov 2026 16:58:29 +0100 Subject: [PATCH 8/9] fix(ad-hoc): resume awaiting completion after automatic redirect --- .../NativeAlternativePaymentDefaultInteractor.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index 6f2c0263f..e1d31d7e6 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -292,11 +292,6 @@ final class NativeAlternativePaymentDefaultInteractor: // MARK: - Awaiting Completion State private func setAwaitingCompletionState(response: NativeAlternativePaymentServiceAdapterResponse) async { - if case .awaitingCompletion(let currentState) = state, !currentState.shouldConfirmPayment { - let failure = POFailure(code: .Mobile.generic) - setFailureState(error: failure, paymentState: response.state) - return - } do { let resolvedElements = try await resolve(elements: response.elements ?? []) let paymentMethod = await resolve(paymentMethod: response.paymentMethod) @@ -304,6 +299,10 @@ final class NativeAlternativePaymentDefaultInteractor: switch state { case .starting, .submitting, .redirecting: break + case .awaitingCompletion: + // Payment could become pending again after an automatic redirect that happened while awaiting + // completion. Polling never resolves with a pending state, so this doesn't cause a loop. + break default: logger.debug("Ignoring attempt to wait for payment confirmation from unsupported state.") return From 8802aa3ce02980e7ac75f51a5151ca1ce87a884f Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Thu, 12 Nov 2026 16:58:29 +0100 Subject: [PATCH 9/9] fix(ad-hoc): re-enable cancellation for states created after delay elapsed --- .../NativeAlternativePaymentDefaultInteractor.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index e1d31d7e6..114e8191d 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -265,6 +265,8 @@ final class NativeAlternativePaymentDefaultInteractor: } let task = Task { @MainActor in try? await Task.sleep(seconds: disabledFor) + // Allows states that are created later to schedule their own enabling. + cancelationEnablingTask = nil switch state { case .started(var currentState): currentState.isCancellable = true @@ -575,6 +577,8 @@ final class NativeAlternativePaymentDefaultInteractor: } let task = Task { @MainActor in try? await Task.sleep(seconds: disabledFor) + // Allows states that are created later to schedule their own enabling. + paymentConfirmationCancellationEnablingTask = nil guard case .awaitingCompletion(var newState) = state else { return }