diff --git a/Example/Example/Resources/Info.plist b/Example/Example/Resources/Info.plist
index aa5dd166c..02c418c4d 100644
--- a/Example/Example/Resources/Info.plist
+++ b/Example/Example/Resources/Info.plist
@@ -4,6 +4,8 @@
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ ProcessOut
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
@@ -47,7 +49,5 @@
UIInterfaceOrientationPortrait
- CFBundleDisplayName
- ProcessOut
diff --git a/Example/Example/Resources/Localizable.xcstrings b/Example/Example/Resources/Localizable.xcstrings
index ea3d5d5cd..a9854500f 100644
--- a/Example/Example/Resources/Localizable.xcstrings
+++ b/Example/Example/Resources/Localizable.xcstrings
@@ -51,6 +51,16 @@
}
}
},
+ "alternative-payments.finalization-mode" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Finalization Mode"
+ }
+ }
+ }
+ },
"alternative-payments.flow" : {
"localizations" : {
"en" : {
diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/Interactor/AlternativePaymentsInteractor.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/Interactor/AlternativePaymentsInteractor.swift
index 990e6117b..844636ec7 100644
--- a/Example/Example/Sources/UI/Modules/AlternativePayments/Interactor/AlternativePaymentsInteractor.swift
+++ b/Example/Example/Sources/UI/Modules/AlternativePayments/Interactor/AlternativePaymentsInteractor.swift
@@ -87,7 +87,11 @@ final class AlternativePaymentsInteractor {
return try await invoicesService.invoice(request: request)
}
- func createInvoice(amount: Decimal, currencyCode: String) async throws -> POInvoice {
+ func createInvoice(
+ amount: Decimal,
+ currencyCode: String,
+ paymentConfiguration: POInvoiceCreationRequest.PaymentConfiguration
+ ) async throws -> POInvoice {
let request = POInvoiceCreationRequest(
name: UUID().uuidString,
amount: amount,
@@ -96,7 +100,8 @@ final class AlternativePaymentsInteractor {
customerId: Example.Constants.customerId,
details: [
.init(name: "Test", amount: amount, quantity: 1)
- ]
+ ],
+ paymentConfiguration: paymentConfiguration,
)
return try await invoicesService.createInvoice(request: request)
}
@@ -146,6 +151,19 @@ final class AlternativePaymentsInteractor {
return try await tokensService.assignCustomerToken(request: tokenAssignRequest, threeDSService: threeDSService)
}
+ /// Advances alternative payment to an authorized state.
+ func authorize(invoiceId: String) async throws {
+ let request = POInvoiceAuthorizationRequest(invoiceId: invoiceId, source: "")
+ let threeDSService = POUnsupported3DS2Service()
+ try await invoicesService.authorizeInvoice(request: request, threeDSService: threeDSService)
+ }
+
+ /// Advances alternative payment to a captured state.
+ func capture(invoiceId: String) async throws {
+ let request = POInvoiceCaptureRequest(invoiceId: invoiceId, source: "")
+ try await invoicesService.captureInvoice(request: request)
+ }
+
func authorize(invoice: POInvoice, customerToken: POCustomerToken) async throws {
let invoiceAuthorizationRequest = POInvoiceAuthorizationRequest(
invoiceId: invoice.id,
diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/Symbols/LocalizedStringResource+AlternativePayments.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/Symbols/LocalizedStringResource+AlternativePayments.swift
index af5f589f6..11d8b3a1a 100644
--- a/Example/Example/Sources/UI/Modules/AlternativePayments/Symbols/LocalizedStringResource+AlternativePayments.swift
+++ b/Example/Example/Sources/UI/Modules/AlternativePayments/Symbols/LocalizedStringResource+AlternativePayments.swift
@@ -43,6 +43,9 @@ extension LocalizedStringResource {
/// Payment flow.
static let flow = LocalizedStringResource("alternative-payments.flow")
+ /// Preferred finalization mode.
+ static let finalizationMode = LocalizedStringResource("alternative-payments.finalization-mode")
+
/// Generic error message.
static let errorMessage = LocalizedStringResource("alternative-payments.error-message")
diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/View/AlternativePaymentsView.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/View/AlternativePaymentsView.swift
index 8766d8e88..29a7581bf 100644
--- a/Example/Example/Sources/UI/Modules/AlternativePayments/View/AlternativePaymentsView.swift
+++ b/Example/Example/Sources/UI/Modules/AlternativePayments/View/AlternativePaymentsView.swift
@@ -38,6 +38,11 @@ struct AlternativePaymentsView: View {
} label: {
Text(.AlternativePayments.flow)
}
+ Picker(data: $viewModel.state.finalizationMode) { mode in
+ Text(mode.rawValue.capitalized)
+ } label: {
+ Text(.AlternativePayments.finalizationMode)
+ }
Toggle(
String(localized: .AlternativePayments.nativePreference),
isOn: $viewModel.state.preferNative
diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift
index 8b47393a5..6920a2e6f 100644
--- a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift
+++ b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift
@@ -8,7 +8,7 @@
import Foundation
import Combine
import SwiftUI
-import ProcessOut
+@_spi(PO) import ProcessOut
import ProcessOutUI
@MainActor
@@ -19,6 +19,7 @@ final class AlternativePaymentsViewModel: ObservableObject {
cancellables = []
observeInteractorStateChanges()
updateStateFlows()
+ updateStateFinalizationModes()
}
// MARK: - AlternativePaymentsViewModel
@@ -133,8 +134,14 @@ final class AlternativePaymentsViewModel: ObservableObject {
state.flow = .init(sources: flows, id: \.self, selection: .payment)
}
+ private func updateStateFinalizationModes() {
+ let modes: [AlternativePaymentsViewModelState.FinalizationMode] = [.automatic, .authorization, .capture]
+ state.finalizationMode = .init(sources: modes, id: \.self, selection: .automatic)
+ }
+
// MARK: -
+ // swiftlint:disable:next function_body_length
private func startPayment() async {
guard let gatewayConfigurationId = state.gatewayConfiguration?.selection else {
return
@@ -142,7 +149,11 @@ final class AlternativePaymentsViewModel: ObservableObject {
do {
let invoice = if state.invoice.id.isEmpty {
try await interactor.createInvoice(
- amount: state.invoice.amount, currencyCode: state.invoice.currencyCode
+ amount: state.invoice.amount,
+ currencyCode: state.invoice.currencyCode,
+ paymentConfiguration: paymentConfiguration(
+ with: state.finalizationMode.selection
+ )
)
} else {
try await interactor.invoice(id: state.invoice.id)
@@ -186,6 +197,17 @@ final class AlternativePaymentsViewModel: ObservableObject {
}
}
+ private func paymentConfiguration(
+ with finalizationMode: AlternativePaymentsViewModelState.FinalizationMode
+ ) -> POInvoiceCreationRequest.PaymentConfiguration {
+ switch finalizationMode {
+ case .automatic:
+ .init(apm: .init(preferredFinalizationMode: .automatic))
+ case .authorization, .capture:
+ .init(apm: .init(preferredFinalizationMode: .manual))
+ }
+ }
+
private func authorizeNatively(
invoice: POInvoice, flow: PONativeAlternativePaymentConfiguration.Flow
) async throws {
@@ -200,8 +222,15 @@ final class AlternativePaymentsViewModel: ObservableObject {
showProgressViewAfter: 5, confirmButton: .init(), cancelButton: .init(disabledFor: 10)
)
)
+ let delegate = NativeAlternativePaymentDelegate(
+ invoiceId: invoice.id,
+ finalizationMode: state.finalizationMode.selection,
+ interactor: interactor
+ )
var continuation: CheckedContinuation?, result: Result?
- let component = PONativeAlternativePaymentComponent(configuration: configuration) { [weak self] paymentResult in
+ let component = PONativeAlternativePaymentComponent(
+ configuration: configuration, delegate: delegate
+ ) { [weak self] paymentResult in
self?.state.nativePayment = nil
continuation?.resume(with: paymentResult)
result = paymentResult
@@ -215,9 +244,75 @@ final class AlternativePaymentsViewModel: ObservableObject {
continuation = newContinuation
state.nativePayment = .init(id: UUID().uuidString, component: component)
}
+ withExtendedLifetime(delegate) { } // Ensure delegate is kept alive for a duration of the payment
}
}
+@MainActor
+private final class NativeAlternativePaymentDelegate: PONativeAlternativePaymentDelegateV2 {
+
+ init(
+ invoiceId: String,
+ finalizationMode: AlternativePaymentsViewModelState.FinalizationMode,
+ interactor: AlternativePaymentsInteractor
+ ) {
+ self.invoiceId = invoiceId
+ self.finalizationMode = finalizationMode
+ self.interactor = interactor
+ }
+
+ // MARK: - PONativeAlternativePaymentDelegateV2
+
+ func nativeAlternativePayment(didEmitEvent event: PONativeAlternativePaymentEventV2) {
+ // Ignored
+ }
+
+ func nativeAlternativePayment(
+ defaultValuesFor parameters: [PONativeAlternativePaymentFormV2.Parameter]
+ ) async -> [String: PONativeAlternativePaymentParameterValue] {
+ [:]
+ }
+
+ func nativeAlternativePayment(
+ finalizeWith request: PONativeAlternativePaymentFinalizeRequestV2
+ ) async throws(POFailure) {
+ do {
+ switch finalizationMode {
+ case .automatic:
+ throw POFailure(
+ message: "Payment with automatic finalization mode is not expected to be finalized manually.",
+ code: .Mobile.generic
+ )
+ case .authorization:
+ guard request.paymentState != .authorized else {
+ return // Payment is already authorized
+ }
+ guard request.availableActions.contains(.authorize) else {
+ throw POFailure(message: "Authorization is not available.", code: .Mobile.generic)
+ }
+ try await interactor.authorize(invoiceId: invoiceId)
+ case .capture:
+ guard request.availableActions.contains(.capture) else {
+ throw POFailure(message: "Capture is not available.", code: .Mobile.generic)
+ }
+ try await interactor.capture(invoiceId: invoiceId)
+ }
+ } catch let failure as POFailure {
+ throw failure
+ } catch {
+ throw POFailure(
+ message: "Unable to finalize payment.", code: .Mobile.generic, underlyingError: error
+ )
+ }
+ }
+
+ // MARK: - Private Properties
+
+ private let invoiceId: String
+ private let finalizationMode: AlternativePaymentsViewModelState.FinalizationMode
+ private let interactor: AlternativePaymentsInteractor
+}
+
extension AlternativePaymentsViewModel {
/// Convenience initializer that resolves its dependencies automatically.
diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModelState.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModelState.swift
index 6c9dcf717..90f52ae3a 100644
--- a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModelState.swift
+++ b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModelState.swift
@@ -12,6 +12,19 @@ import ProcessOutUI
struct AlternativePaymentsViewModelState {
+ /// Describes how payment is expected to be finalized once all customer actions are completed.
+ enum FinalizationMode: String, Hashable {
+
+ /// Backend decides how to finalize payment.
+ case automatic
+
+ /// Payment is expected to be manually advanced to an authorized state.
+ case authorization
+
+ /// Payment is expected to be manually captured.
+ case capture
+ }
+
enum Flow: String, Hashable {
/// One time payment.
@@ -62,8 +75,15 @@ struct AlternativePaymentsViewModelState {
/// Boolean value indicating whether native flow should be preferred if available.
var preferNative = false
+ // swiftlint:disable implicitly_unwrapped_optional
+
/// Payment flow.
- var flow: PickerData! // swiftlint:disable:this implicitly_unwrapped_optional
+ var flow: PickerData!
+
+ /// Mode that describes how payment is finalized once all customer actions are completed.
+ var finalizationMode: PickerData!
+
+ // swiftlint:enable implicitly_unwrapped_optional
/// Currently presented native alternative payment.
var nativePayment: NativePayment?
diff --git a/Sources/ProcessOut/Sources/Api/Utils/Unsupported3DS/POUnsupported3DS2Service.swift b/Sources/ProcessOut/Sources/Api/Utils/Unsupported3DS/POUnsupported3DS2Service.swift
new file mode 100644
index 000000000..464f80fa5
--- /dev/null
+++ b/Sources/ProcessOut/Sources/Api/Utils/Unsupported3DS/POUnsupported3DS2Service.swift
@@ -0,0 +1,35 @@
+//
+// POUnsupported3DS2Service.swift
+// ProcessOut
+//
+// Created by Andrii Vysotskyi on 21.09.2026.
+//
+
+/// Service that doesn't implement 3DS authentication and fails whenever one is requested. Intended for flows where
+/// a 3DS challenge is not expected, for example an alternative payment authorization.
+@_spi(PO)
+public struct POUnsupported3DS2Service: PO3DS2Service {
+
+ /// Creates service instance.
+ public init() {
+ // Ignored
+ }
+
+ // MARK: - PO3DS2Service
+
+ public func authenticationRequestParameters(
+ configuration: PO3DS2Configuration
+ ) async throws -> PO3DS2AuthenticationRequestParameters {
+ throw POFailure(
+ message: "Unable to create authentication request parameters: 3DS is not supported.",
+ code: .Mobile.generic
+ )
+ }
+
+ public func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult {
+ throw POFailure(
+ message: "Unable to perform challenge: 3DS is not supported.",
+ code: .Mobile.generic
+ )
+ }
+}
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..e2501e152
--- /dev/null
+++ b/Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift
@@ -0,0 +1,19 @@
+//
+// 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) {
+ do {
+ try Task.checkCancellation()
+ } catch {
+ throw POFailure(message: "Task was cancelled.", code: .Mobile.cancelled, underlyingError: error)
+ }
+ }
+}
diff --git a/Sources/ProcessOut/Sources/Repositories/CustomerTokens/Responses/PONativeAlternativePaymentTokenizationResponseV2.swift b/Sources/ProcessOut/Sources/Repositories/CustomerTokens/Responses/PONativeAlternativePaymentTokenizationResponseV2.swift
index 37a9c6253..c9310d09c 100644
--- a/Sources/ProcessOut/Sources/Repositories/CustomerTokens/Responses/PONativeAlternativePaymentTokenizationResponseV2.swift
+++ b/Sources/ProcessOut/Sources/Repositories/CustomerTokens/Responses/PONativeAlternativePaymentTokenizationResponseV2.swift
@@ -20,4 +20,7 @@ public struct PONativeAlternativePaymentTokenizationResponseV2: Sendable, Decoda
/// Redirect details.
public let redirect: PONativeAlternativePaymentRedirectV2?
+
+ /// Currently available actions to advance payment.
+ public let availableActions: [PONativeAlternativePaymentAvailableActionV2]?
}
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/HttpInvoicesRepository.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/HttpInvoicesRepository.swift
index f9260fec9..1dc683819 100644
--- a/Sources/ProcessOut/Sources/Repositories/Invoices/HttpInvoicesRepository.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/HttpInvoicesRepository.swift
@@ -82,6 +82,17 @@ final class HttpInvoicesRepository: InvoicesRepository {
return try await connector.execute(request: httpRequest)
}
+ func captureInvoice(request: POInvoiceCaptureRequest) async throws {
+ struct Request: Encodable, Sendable {
+ let source: String
+ }
+ let requestBox = Request(source: request.source)
+ let httpRequest = HttpConnectorRequest.post(
+ path: "/invoices/\(request.invoiceId)/capture", body: requestBox
+ )
+ _ = try await connector.execute(request: httpRequest)
+ }
+
// MARK: - Deprecated
func nativeAlternativePaymentMethodTransactionDetails(
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/InvoicesRepository.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/InvoicesRepository.swift
index d6a869302..c60eb63dc 100644
--- a/Sources/ProcessOut/Sources/Repositories/Invoices/InvoicesRepository.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/InvoicesRepository.swift
@@ -30,6 +30,11 @@ protocol InvoicesRepository: PORepository {
request: PONativeAlternativePaymentUrlResolutionRequestV2
) async throws -> PONativeAlternativePaymentUrlResolutionResponseV2
+ // MARK: - Invoice Capture
+
+ /// Captures invoice with a given request.
+ func captureInvoice(request: POInvoiceCaptureRequest) async throws
+
// MARK: - Alternative Payment (Deprecated)
/// Requests information needed to continue existing payment or start new one.
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCaptureRequest.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCaptureRequest.swift
new file mode 100644
index 000000000..30da9a1cd
--- /dev/null
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCaptureRequest.swift
@@ -0,0 +1,21 @@
+//
+// POInvoiceCaptureRequest.swift
+// ProcessOut
+//
+// Created by Andrii Vysotskyi on 21.09.2026.
+//
+
+@_spi(PO)
+public struct POInvoiceCaptureRequest: Sendable {
+
+ /// Invoice identifier.
+ public let invoiceId: String
+
+ /// Payment source, for example gateway configuration identifier or customer token identifier.
+ public let source: String
+
+ public init(invoiceId: String, source: String) {
+ self.invoiceId = invoiceId
+ self.source = source
+ }
+}
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift
index e775e7f9f..5fdeb55bd 100644
--- a/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Requests/POInvoiceCreationRequest.swift
@@ -7,9 +7,46 @@
import Foundation
+// swiftlint:disable nesting
+
@_spi(PO)
public struct POInvoiceCreationRequest: Encodable, Sendable {
+ /// Payment configuration of the invoice.
+ public struct PaymentConfiguration: Encodable, Sendable {
+
+ /// Configuration specific to alternative payment methods.
+ public struct AlternativePaymentMethod: Encodable, Sendable {
+
+ /// Describes how payment is finalized once all customer actions are completed.
+ public enum PreferredFinalizationMode: String, Encodable, Sendable {
+
+ /// For supported payment methods, payment transitions to
+ /// ``PONativeAlternativePaymentStateV2/customerActionsCompleted`` and the caller is expected to
+ /// explicitly advance it to an authorized and/or captured state.
+ case manual
+
+ /// Backend decides how to finalize the invoice, usually by capturing it.
+ case automatic
+ }
+
+ /// Preferred finalization mode. When not set, backend falls back to
+ /// ``PreferredFinalizationMode/automatic``.
+ public let preferredFinalizationMode: PreferredFinalizationMode?
+
+ public init(preferredFinalizationMode: PreferredFinalizationMode?) {
+ self.preferredFinalizationMode = preferredFinalizationMode
+ }
+ }
+
+ /// Alternative payment method configuration.
+ public let apm: AlternativePaymentMethod?
+
+ public init(apm: AlternativePaymentMethod?) {
+ self.apm = apm
+ }
+ }
+
/// Invoice detail item.
public struct Detail: Encodable, Sendable {
@@ -50,13 +87,17 @@ public struct POInvoiceCreationRequest: Encodable, Sendable {
/// Invoice details.
public let details: [Detail]
+ /// Payment configuration.
+ public let paymentConfiguration: PaymentConfiguration?
+
public init(
name: String,
amount: Decimal,
currency: String,
returnUrl: URL? = nil,
customerId: String? = nil,
- details: [Detail] = []
+ details: [Detail] = [],
+ paymentConfiguration: PaymentConfiguration? = nil,
) {
self.name = name
self._amount = .init(value: amount)
@@ -64,5 +105,8 @@ public struct POInvoiceCreationRequest: Encodable, Sendable {
self.returnUrl = returnUrl
self.customerId = customerId
self.details = details
+ self.paymentConfiguration = paymentConfiguration
}
}
+
+// swiftlint:enable nesting
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentAuthorizationResponseV2.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentAuthorizationResponseV2.swift
index b88095719..63c99400a 100644
--- a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentAuthorizationResponseV2.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentAuthorizationResponseV2.swift
@@ -23,4 +23,7 @@ public struct PONativeAlternativePaymentAuthorizationResponseV2: Sendable, Decod
/// Redirect details.
public let redirect: PONativeAlternativePaymentRedirectV2?
+
+ /// Currently available actions to advance payment.
+ public let availableActions: [PONativeAlternativePaymentAvailableActionV2]?
}
diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentUrlResolutionResponseV2.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentUrlResolutionResponseV2.swift
index 9b246a9c8..73d85c557 100644
--- a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentUrlResolutionResponseV2.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/AlternativePaymentV2/PONativeAlternativePaymentUrlResolutionResponseV2.swift
@@ -33,4 +33,7 @@ public struct PONativeAlternativePaymentUrlResolutionResponseV2: Sendable, Decod
/// Redirect details.
public let redirect: PONativeAlternativePaymentRedirectV2?
+
+ /// Currently available actions to advance payment.
+ public let availableActions: [PONativeAlternativePaymentAvailableActionV2]?
}
diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentAvailableActionV2.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentAvailableActionV2.swift
new file mode 100644
index 000000000..6c2c27baf
--- /dev/null
+++ b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentAvailableActionV2.swift
@@ -0,0 +1,38 @@
+//
+// PONativeAlternativePaymentAvailableActionV2.swift
+// ProcessOut
+//
+// Created by Andrii Vysotskyi on 11.08.2026.
+//
+
+public struct PONativeAlternativePaymentAvailableActionV2: Sendable, Hashable {
+
+ /// The string value representing the action.
+ public let rawValue: String
+
+ public init(rawValue: String) {
+ self.rawValue = rawValue
+ }
+}
+
+extension PONativeAlternativePaymentAvailableActionV2: Codable {
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ rawValue = try container.decode(String.self)
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(self.rawValue)
+ }
+}
+
+extension PONativeAlternativePaymentAvailableActionV2 {
+
+ /// Authorization is possible.
+ public static let authorize = Self(rawValue: "AUTHORIZE")
+
+ /// Capture is possible.
+ public static let capture = Self(rawValue: "CAPTURE")
+}
diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentStateV2.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentStateV2.swift
index 490409ed4..2f8c6c313 100644
--- a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentStateV2.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/AlternativePaymentV2/PONativeAlternativePaymentStateV2.swift
@@ -37,6 +37,16 @@ extension PONativeAlternativePaymentStateV2 {
/// Payment is pending.
public static let pending = Self(rawValue: "PENDING")
+ /// Customer interaction completed. Explicit authorize and/or capture is expected to advance payment.
+ public static let customerActionsCompleted = Self(rawValue: "CUSTOMER_ACTIONS_COMPLETED")
+
+ /// Authorization was requested and it’s still pending. No further actions are expected, payment will
+ /// advance to next state automatically.
+ public static let authorizationPending = Self(rawValue: "AUTHORIZATION_PENDING")
+
+ /// Authorization was requested and completed.
+ public static let authorized = Self(rawValue: "AUTHORIZED")
+
/// Payment is successfully completed.
public static let success = Self(rawValue: "SUCCESS")
}
diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift
index 156068d55..94eb7e6fc 100644
--- a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift
+++ b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift
@@ -368,6 +368,11 @@ 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"
+ )
}
public enum Request {
diff --git a/Sources/ProcessOut/Sources/Services/Invoices/DefaultInvoicesService.swift b/Sources/ProcessOut/Sources/Services/Invoices/DefaultInvoicesService.swift
index 5e735c401..ecb4910d0 100644
--- a/Sources/ProcessOut/Sources/Services/Invoices/DefaultInvoicesService.swift
+++ b/Sources/ProcessOut/Sources/Services/Invoices/DefaultInvoicesService.swift
@@ -56,6 +56,10 @@ final class DefaultInvoicesService: POInvoicesService {
try await repository.resolveUrl(request: request)
}
+ func captureInvoice(request: POInvoiceCaptureRequest) async throws {
+ try await repository.captureInvoice(request: request)
+ }
+
// MARK: - Deprecated
func nativeAlternativePaymentMethodTransactionDetails(
diff --git a/Sources/ProcessOut/Sources/Services/Invoices/POInvoicesService.swift b/Sources/ProcessOut/Sources/Services/Invoices/POInvoicesService.swift
index f703164b3..d1875710b 100644
--- a/Sources/ProcessOut/Sources/Services/Invoices/POInvoicesService.swift
+++ b/Sources/ProcessOut/Sources/Services/Invoices/POInvoicesService.swift
@@ -39,6 +39,13 @@ public protocol POInvoicesService: POService { // sourcery: AutoCompletion
request: PONativeAlternativePaymentUrlResolutionRequestV2
) async throws -> PONativeAlternativePaymentUrlResolutionResponseV2
+ /// Captures invoice with a given request.
+ ///
+ /// Unlike ``captureNativeAlternativePayment(request:)`` capture is attempted only once, so method either
+ /// succeeds or fails.
+ @_spi(PO)
+ func captureInvoice(request: POInvoiceCaptureRequest) async throws // sourcery:completion: skip
+
// MARK: - Alternative Payment (Deprecated)
/// Requests information needed to continue existing payment or start new one.
@@ -78,4 +85,9 @@ extension POInvoicesService {
) async throws -> PONativeAlternativePaymentUrlResolutionResponseV2 {
throw POFailure(code: .Mobile.generic)
}
+
+ @_spi(PO)
+ public func captureInvoice(request: POInvoiceCaptureRequest) async throws {
+ throw POFailure(code: .Mobile.generic)
+ }
}
diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutAlternativePaymentFinalizeRequest.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutAlternativePaymentFinalizeRequest.swift
new file mode 100644
index 000000000..b7d161ba8
--- /dev/null
+++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutAlternativePaymentFinalizeRequest.swift
@@ -0,0 +1,23 @@
+//
+// PODynamicCheckoutAlternativePaymentFinalizeRequest.swift
+// ProcessOutUI
+//
+// Created by Andrii Vysotskyi on 22.09.2026.
+//
+
+@_spi(PO) import ProcessOut
+
+/// Request to finalize alternative payment.
+@_spi(PO)
+public struct PODynamicCheckoutAlternativePaymentFinalizeRequest {
+
+ /// Payment method details.
+ public let paymentMethod: PODynamicCheckoutPaymentMethod.NativeAlternativePayment
+
+ /// Current payment state. Either ``PONativeAlternativePaymentStateV2/customerActionsCompleted`` or
+ /// ``PONativeAlternativePaymentStateV2/authorized`` when payment could still be captured.
+ public let paymentState: PONativeAlternativePaymentStateV2
+
+ /// Currently available actions to advance payment.
+ public let availableActions: [PONativeAlternativePaymentAvailableActionV2]
+}
diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift
index 09bc7ded4..85d019e27 100644
--- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift
@@ -92,6 +92,17 @@ public protocol PODynamicCheckoutDelegate: AnyObject, Sendable {
alternativePaymentDefaultsWith request: PODynamicCheckoutAlternativePaymentDefaultsRequest
) async -> [String: PONativeAlternativePaymentParameterValue]
+ /// Asks delegate to finalize alternative payment by explicitly advancing it to an authorized and/or captured
+ /// state using one of the available actions. Method is only invoked when invoice was created with manual
+ /// finalization mode.
+ ///
+ /// Method is invoked at most once per payment attempt. If payment is already authorized, implementation may
+ /// return without advancing payment to leave it authorized.
+ @MainActor
+ func dynamicCheckout(
+ finalizeAlternativePaymentWith request: PODynamicCheckoutAlternativePaymentFinalizeRequest
+ ) async throws(POFailure)
+
// MARK: - Pass Kit
/// Gives implementation an opportunity to modify payment request before it is used to authorize invoice.
@@ -172,6 +183,14 @@ extension PODynamicCheckoutDelegate {
[:]
}
+ @MainActor
+ public func dynamicCheckout(
+ finalizeAlternativePaymentWith request: PODynamicCheckoutAlternativePaymentFinalizeRequest
+ ) async throws(POFailure) {
+ assertionFailure("Method must be implemented when manual finalization is used.")
+ throw .init(message: "Manual finalization is not implemented.", code: .Mobile.generic)
+ }
+
// MARK: - Pass Kit
@MainActor
diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift
index e570396db..8e24e654a 100644
--- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift
@@ -667,6 +667,7 @@ final class DynamicCheckoutDefaultInteractor:
interactor.start()
}
+ // swiftlint:disable:next cyclomatic_complexity
private func nativeAlternativePayment(willChangeState state: NativeAlternativePaymentInteractorState) {
guard case .paymentProcessing(var currentState) = self.state,
case .nativeAlternativePayment = currentState.paymentMethod else {
@@ -706,6 +707,11 @@ final class DynamicCheckoutDefaultInteractor:
currentState.isReady = true
currentState.isAwaitingNativeAlternativePaymentCapture = true
self.state = .paymentProcessing(currentState)
+ case .finalizing:
+ // Capture flag is intentionally preserved so cancel button keeps its appearance.
+ currentState.isCancellable = false
+ currentState.isReady = true
+ self.state = .paymentProcessing(currentState)
case .completed:
setSuccessState(authorizationOutcome: .success)
case .failure(let failure):
@@ -1026,6 +1032,25 @@ extension DynamicCheckoutDefaultInteractor: PONativeAlternativePaymentDelegateV2
)
return await delegate?.dynamicCheckout(alternativePaymentDefaultsWith: request) ?? [:]
}
+
+ func nativeAlternativePayment(
+ finalizeWith request: PONativeAlternativePaymentFinalizeRequestV2
+ ) async throws(POFailure) {
+ guard case .paymentProcessing(let currentState) = state,
+ case .nativeAlternativePayment(let paymentMethod) = currentState.paymentMethod else {
+ logger.error("Unable to finalize payment in current state: \(state).")
+ throw POFailure(message: "Unable to finalize payment in current state.", code: .Mobile.generic)
+ }
+ guard let delegate else {
+ throw POFailure(message: "Delegate is not set, unable to finalize payment.", code: .Mobile.internal)
+ }
+ let dynamicCheckoutRequest = PODynamicCheckoutAlternativePaymentFinalizeRequest(
+ paymentMethod: paymentMethod,
+ paymentState: request.paymentState,
+ availableActions: request.availableActions
+ )
+ try await delegate.dynamicCheckout(finalizeAlternativePaymentWith: dynamicCheckoutRequest)
+ }
}
// swiftlint:enable file_length type_body_length
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentDelegateV2.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentDelegateV2.swift
index 51d2c55ce..f59f76159 100644
--- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentDelegateV2.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentDelegateV2.swift
@@ -22,4 +22,26 @@ public protocol PONativeAlternativePaymentDelegateV2: AnyObject, Sendable {
func nativeAlternativePayment(
defaultValuesFor parameters: [PONativeAlternativePaymentFormV2.Parameter]
) async -> [String: PONativeAlternativePaymentParameterValue]
+
+ /// Asks delegate to finalize payment by explicitly advancing it to an authorized and/or captured state
+ /// using one of the available actions.
+ ///
+ /// Method is invoked at most once, either when all customer actions are completed or when payment is
+ /// already authorized and could still be captured. In the latter case implementation may return without
+ /// advancing payment to leave it authorized.
+ @MainActor
+ func nativeAlternativePayment(
+ finalizeWith request: PONativeAlternativePaymentFinalizeRequestV2
+ ) async throws(POFailure)
+}
+
+extension PONativeAlternativePaymentDelegateV2 {
+
+ @MainActor
+ public func nativeAlternativePayment(
+ finalizeWith request: PONativeAlternativePaymentFinalizeRequestV2
+ ) async throws(POFailure) {
+ assertionFailure("Method must be implemented when manual finalization is used.")
+ throw .init(message: "Manual finalization is not implemented.", code: .Mobile.generic)
+ }
}
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentFinalizeRequestV2.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentFinalizeRequestV2.swift
new file mode 100644
index 000000000..ac7c67d7c
--- /dev/null
+++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Delegate/PONativeAlternativePaymentFinalizeRequestV2.swift
@@ -0,0 +1,19 @@
+//
+// PONativeAlternativePaymentFinalizeRequestV2.swift
+// ProcessOutUI
+//
+// Created by Andrii Vysotskyi on 28.09.2026.
+//
+
+import ProcessOut
+
+/// Request to finalize native alternative payment.
+public struct PONativeAlternativePaymentFinalizeRequestV2: Sendable {
+
+ /// Current payment state. Either ``PONativeAlternativePaymentStateV2/customerActionsCompleted`` or
+ /// ``PONativeAlternativePaymentStateV2/authorized`` when payment could still be captured.
+ public let paymentState: PONativeAlternativePaymentStateV2
+
+ /// Currently available actions to advance payment.
+ public let availableActions: [PONativeAlternativePaymentAvailableActionV2]
+}
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/DefaultNativeAlternativePaymentServiceAdapter.swift
index 59f37e761..c6154de78 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 || response.state == .authorizationPending
case let .failure(failure as POFailure):
let retriableCodes: [POFailureCode] = [
.Mobile.networkUnreachable, .Mobile.timeout, .Mobile.internal
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/NativeAlternativePaymentServiceAdapterResponse.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/NativeAlternativePaymentServiceAdapterResponse.swift
index c937c9b2c..81ca8a77a 100644
--- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/NativeAlternativePaymentServiceAdapterResponse.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/Adapter/NativeAlternativePaymentServiceAdapterResponse.swift
@@ -23,6 +23,9 @@ struct NativeAlternativePaymentServiceAdapterResponse {
/// Redirect details.
let redirect: PONativeAlternativePaymentRedirectV2?
+
+ /// Currently available actions to advance payment.
+ let availableActions: [PONativeAlternativePaymentAvailableActionV2]?
}
extension NativeAlternativePaymentServiceAdapterResponse {
@@ -33,6 +36,7 @@ extension NativeAlternativePaymentServiceAdapterResponse {
self.invoice = authorizationResponse.invoice
self.elements = authorizationResponse.elements
self.redirect = authorizationResponse.redirect
+ self.availableActions = authorizationResponse.availableActions
}
init(tokenizationResponse: PONativeAlternativePaymentTokenizationResponseV2) {
@@ -41,5 +45,6 @@ extension NativeAlternativePaymentServiceAdapterResponse {
self.invoice = nil
self.elements = tokenizationResponse.elements
self.redirect = tokenizationResponse.redirect
+ self.availableActions = tokenizationResponse.availableActions
}
}
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift
index 50b2d1f12..166edf5ff 100644
--- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift
@@ -54,14 +54,21 @@ final class NativeAlternativePaymentDefaultInteractor:
logger.info("Starting native alternative payment.")
send(event: .willStart)
let task = Task { @MainActor in
+ let payment: NativeAlternativePaymentServiceAdapterResponse
do {
let request = NativeAlternativePaymentServiceAdapterRequest(
- flow: configuration.flow, localeIdentifier: configuration.localization.localeOverride?.identifier
+ flow: configuration.flow,
+ localeIdentifier: configuration.localization.localeOverride?.identifier
)
- let payment = try await serviceAdapter.continuePayment(with: request)
+ payment = try await serviceAdapter.continuePayment(with: request)
+ } catch {
+ setFailureState(error: error, paymentState: nil)
+ return
+ }
+ do {
try await setState(with: payment)
} catch {
- setFailureState(error: error)
+ setFailureState(error: error, paymentState: payment.state)
}
}
state = .starting(.init(task: task))
@@ -97,6 +104,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,28 +112,28 @@ final class NativeAlternativePaymentDefaultInteractor:
submitData: .init(parameters: values),
localeIdentifier: configuration.localization.localeOverride?.identifier
)
- let payment = try await serviceAdapter.continuePayment(with: request)
+ payment = try await serviceAdapter.continuePayment(with: request)
+ } catch {
+ attemptRecoverSubmissionError(error)
+ return
+ }
+ do {
+ try Task.poCheckCancellation()
let submittedParametersSpecifications = Array(currentState.parameters.values.map(\.specification))
switch payment.state {
case .nextStepRequired:
- send(
- event: .didSubmitParameters(
- .init(parameters: submittedParametersSpecifications, additionalParametersExpected: true)
- )
- )
+ 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))
+ send(event: .didSubmitParameters(
+ .init(parameters: submittedParametersSpecifications, additionalParametersExpected: false)
+ ))
}
try await setState(with: payment)
} catch {
- attemptRecoverSubmissionError(error)
+ setFailureState(error: error, paymentState: payment.state)
}
}
state = .submitting(.init(snapshot: currentState, task: task))
@@ -144,7 +152,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)
@@ -169,18 +177,22 @@ final class NativeAlternativePaymentDefaultInteractor:
currentState.task?.cancel()
case .redirecting(let currentState):
currentState.task.cancel()
+ case .finalizing(let currentState):
+ currentState.task.cancel()
case .completed(let currentState):
// 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
)
}
@@ -204,14 +216,18 @@ final class NativeAlternativePaymentDefaultInteractor:
} else {
try await setStartedState(response: response)
}
- case .pending:
+ case .pending, .authorizationPending:
await setAwaitingCompletionState(response: response)
- case .success:
+ case .customerActionsCompleted:
+ try setFinalizingPaymentState(with: response)
+ case .authorized where !didRequestFinalization && response.availableActions?.contains(.capture) == true:
+ // Payment could be authorized without an explicit request, so delegate is given an opportunity to capture it.
+ try setFinalizingPaymentState(with: response)
+ case .authorized, .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)
+ throw POFailure(message: "Something went wrong.", code: .Mobile.generic)
}
}
@@ -221,8 +237,9 @@ 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:
+ case .starting, .submitting, .redirecting, .finalizing:
break // todo(andrii-vysotskyi): check if more states should be supported
default:
logger.debug("Ignoring attempt to set started state in unsupported state: \(state).")
@@ -234,6 +251,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,13 +302,15 @@ 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:
+ case .starting, .submitting, .redirecting, .finalizing:
break
default:
logger.debug("Ignoring attempt to wait for payment confirmation from unsupported state.")
@@ -301,8 +321,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 +335,7 @@ final class NativeAlternativePaymentDefaultInteractor:
}
enablePaymentConfirmationCancellationAfterDelay()
} catch {
- setFailureState(error: error)
+ setFailureState(error: error, paymentState: response.state)
}
}
@@ -339,7 +360,7 @@ final class NativeAlternativePaymentDefaultInteractor:
let response = try await serviceAdapter.expectPaymentCompletion(with: request)
try await setState(with: response)
} catch {
- setFailureState(error: error)
+ setFailureState(error: error, paymentState: currentState.paymentState)
}
}
newState.estimatedCompletionDate = Date().addingTimeInterval(configuration.paymentConfirmation.timeout)
@@ -357,8 +378,9 @@ 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:
+ case .starting, .submitting, .redirecting, .finalizing:
break // todo(andrii-vysotskyi): check if more states should be supported
default:
logger.debug("Ignoring attempt to set started state in unsupported state: \(state).")
@@ -367,6 +389,7 @@ final class NativeAlternativePaymentDefaultInteractor:
let newState = State.AwaitingRedirect(
paymentMethod: paymentMethod,
invoice: response.invoice,
+ paymentState: response.state,
elements: elements,
redirect: redirect,
isCancellable: configuration.cancelButton?.disabledFor.isZero ?? true
@@ -393,6 +416,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))
)
@@ -432,11 +456,72 @@ final class NativeAlternativePaymentDefaultInteractor:
return await UIApplication.shared.open(url, options: options)
}
+ // MARK: - Payment Finalization
+
+ /// - NOTE: Delegate is asked to finalize payment at most once.
+ private func setFinalizingPaymentState(with response: NativeAlternativePaymentServiceAdapterResponse) throws {
+ try Task.poCheckCancellation()
+ guard !didRequestFinalization else {
+ // Delegate is expected to advance payment, so asking it again would most likely result in an endless loop.
+ throw POFailure(message: "Payment was not advanced during finalization.", code: .Mobile.generic)
+ }
+ guard let snapshot = finalizingStateSnapshot() else {
+ logger.debug("Ignoring attempt to finalize payment in unsupported state: \(state).")
+ return
+ }
+ guard let availableActions = response.availableActions else {
+ throw POFailure(message: "No available actions to finalize payment.", code: .Mobile.internal)
+ }
+ guard let delegate else {
+ throw POFailure(message: "Delegate is not set, unable to finalize payment.", code: .Mobile.internal)
+ }
+ didRequestFinalization = true
+ let request = PONativeAlternativePaymentFinalizeRequestV2(
+ paymentState: response.state, availableActions: availableActions
+ )
+ let task = Task { @MainActor [configuration] in
+ do {
+ try await delegate.nativeAlternativePayment(finalizeWith: request)
+ let finalizedPaymentResponse = try await serviceAdapter.continuePayment(
+ with: .init(
+ flow: configuration.flow,
+ localeIdentifier: configuration.localization.localeOverride?.identifier
+ )
+ )
+ try await setState(with: finalizedPaymentResponse)
+ } catch {
+ setFailureState(error: error, paymentState: response.state)
+ }
+ }
+ state = .finalizing(.init(paymentState: response.state, snapshot: snapshot, task: task))
+ logger.info("Waiting for payment to be finalized.")
+ }
+
+ private func finalizingStateSnapshot() -> NativeAlternativePaymentInteractorState.Finalizing.Snapshot? {
+ switch state {
+ case .starting(let currentState):
+ return .starting(currentState)
+ case .submitting(let currentState):
+ return .submitting(currentState)
+ case .redirecting(let currentState):
+ return .redirecting(currentState)
+ case .awaitingCompletion(let currentState):
+ return .awaitingCompletion(currentState)
+ case .idle, .started, .awaitingRedirect, .finalizing, .completed, .failure:
+ return nil // Payment could only be finalized as a result of an ongoing operation
+ }
+ }
+
+ /// Boolean value indicating whether delegate was already asked to finalize payment.
+ private var didRequestFinalization = false
+
// MARK: - Completed State
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 +538,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 +550,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 +572,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 +595,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 +618,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
@@ -976,14 +1059,17 @@ final class NativeAlternativePaymentDefaultInteractor:
paymentMethod: response.paymentMethod,
invoice: response.invoice,
elements: response.elements,
- redirect: response.redirect
+ redirect: response.redirect,
+ availableActions: response.availableActions
)
Task { @MainActor in
do {
switch state {
case .idle, .started, .awaitingRedirect:
try await setState(with: adapterResponse)
- case .awaitingCompletion(let currentState) where adapterResponse.state != .pending:
+ case .awaitingCompletion(let currentState)
+ where ![.pending, .authorizationPending].contains(adapterResponse.state):
+
currentState.task?.cancel()
try await setState(with: adapterResponse)
default:
@@ -991,7 +1077,7 @@ final class NativeAlternativePaymentDefaultInteractor:
return
}
} catch {
- setFailureState(error: error)
+ setFailureState(error: error, paymentState: response.state)
}
}
}
@@ -1004,7 +1090,12 @@ 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.
+ let ignoredFailureCodes: [POFailureCode] = [
+ .RequestValidation.redirectResultInvalid,
+ .RequestValidation.redirectResultUnexpected
+ ]
+ guard !ignoredFailureCodes.contains(failure.failureCode) else {
return
}
switch state {
@@ -1016,12 +1107,14 @@ final class NativeAlternativePaymentDefaultInteractor:
currentState.task.cancel()
case .awaitingCompletion(let currentState):
currentState.task?.cancel()
+ case .finalizing:
+ return // Customer actions are already completed, so finalization outcome takes precedence.
case .failure, .completed:
return
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..067cf7009 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]
@@ -97,6 +106,34 @@ enum NativeAlternativePaymentInteractorState {
var shouldConfirmPayment: Bool
}
+ struct Finalizing {
+
+ /// State that was active when finalization started.
+ enum Snapshot { // swiftlint:disable:this nesting
+
+ /// Interactor was loading initial content.
+ case starting(Starting)
+
+ /// Parameters were being submitted.
+ case submitting(Submitting)
+
+ /// User was being redirected.
+ case redirecting(Redirecting)
+
+ /// Interactor was awaiting payment completion.
+ case awaitingCompletion(AwaitingCompletion)
+ }
+
+ /// Payment state.
+ let paymentState: PONativeAlternativePaymentStateV2
+
+ /// Snapshot of the state that was active when finalization started.
+ let snapshot: Snapshot
+
+ /// Finalization task.
+ let task: Task
+ }
+
struct Completed {
/// Payment method information.
@@ -105,6 +142,9 @@ enum NativeAlternativePaymentInteractorState {
/// Invoice information if any.
let invoice: PONativeAlternativePaymentInvoiceV2?
+ /// Payment state.
+ let paymentState: PONativeAlternativePaymentStateV2
+
/// Resolved elements.
let elements: [NativeAlternativePaymentResolvedElement]
@@ -151,6 +191,9 @@ enum NativeAlternativePaymentInteractorState {
/// Parameters were submitted and accepted.
case awaitingCompletion(AwaitingCompletion)
+ /// Delegate is finalizing payment.
+ case finalizing(Finalizing)
+
/// Payment is completed.
case completed(Completed)
}
@@ -171,11 +214,37 @@ extension NativeAlternativePaymentInteractorState: InteractorState {
return state
case .submitting(let state):
return state.snapshot
+ case .finalizing(let state):
+ if case .submitting(let submittingState) = state.snapshot {
+ return submittingState.snapshot
+ }
+ return nil
default:
return nil
}
}
+ 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 .finalizing(let state):
+ state.paymentState
+ case .completed(let state):
+ state.paymentState
+ }
+ }
+
var isSink: Bool {
switch self {
case .completed, .failure:
diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift
index 59c51c3a3..4f9b17daf 100644
--- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift
+++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/ViewModel/DefaultNativeAlternativePaymentViewModel.swift
@@ -73,6 +73,8 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
update(with: state)
case .awaitingCompletion(let state):
update(with: state)
+ case .finalizing(let state):
+ update(with: state)
case .completed(let state) where configuration.success != nil:
update(with: state)
default:
@@ -130,7 +132,7 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
newState: InteractorState.Started, oldState: InteractorState
) -> AnyHashable? {
switch oldState {
- case .idle, .starting, .submitting, .redirecting:
+ case .idle, .starting, .submitting, .redirecting, .finalizing:
break
default:
let isFocusedParameterAvailable = newState.parameters.values
@@ -163,12 +165,12 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
// MARK: - Awaiting Capture State
- private func update(with state: InteractorState.AwaitingCompletion) {
+ private func update(with state: InteractorState.AwaitingCompletion, isFinalizing: Bool = false) {
let newState = NativeAlternativePaymentViewModelState(
items: createItems(state: state),
focusedItemId: nil,
confirmationDialog: nil,
- controls: createFormControlGroup(state: state)
+ controls: createFormControlGroup(state: state, isFinalizing: isFinalizing)
)
self.state = newState
}
@@ -216,10 +218,10 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
}
private func createFormControlGroup(
- state: InteractorState.AwaitingCompletion
+ state: InteractorState.AwaitingCompletion, isFinalizing: Bool
) -> NativeAlternativePaymentViewModelControlGroup? {
let buttons = [
- createConfirmPaymentButton(state: state),
+ createConfirmPaymentButton(state: state, isLoading: isFinalizing),
createCancelButton(
configuration: configuration.paymentConfirmation.cancelButton,
isEnabled: state.isCancellable
@@ -231,7 +233,9 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
return .init(buttons: buttons, inline: configuration.prefersInlineControls)
}
- private func createConfirmPaymentButton(state: InteractorState.AwaitingCompletion) -> POButtonViewModel? {
+ private func createConfirmPaymentButton(
+ state: InteractorState.AwaitingCompletion, isLoading: Bool
+ ) -> POButtonViewModel? {
guard state.shouldConfirmPayment else {
return nil
}
@@ -249,6 +253,7 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
id: "primary-button",
title: buttonConfiguration.title,
icon: buttonConfiguration.icon,
+ isLoading: isLoading,
role: .primary,
accessibilityLabel: buttonConfiguration.title ?? defaultTitle,
action: { [weak self] in
@@ -348,6 +353,25 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel {
return viewModel
}
+ // MARK: - Finalizing State
+
+ /// Keeps content of the state that was active when finalization started but disables interactions.
+ private func update(with state: InteractorState.Finalizing) {
+ switch state.snapshot {
+ case .starting:
+ updateWithStartingState()
+ case .submitting(let submittingState):
+ update(withSubmittingState: submittingState.snapshot)
+ case .redirecting(let redirectingState):
+ var awaitingRedirectState = redirectingState.snapshot
+ awaitingRedirectState.isCancellable = false
+ update(with: awaitingRedirectState, isRedirecting: true)
+ case .awaitingCompletion(var awaitingCompletionState):
+ awaitingCompletionState.isCancellable = false
+ update(with: awaitingCompletionState, isFinalizing: true)
+ }
+ }
+
// MARK: - Completed State
private func update(with state: InteractorState.Completed) {