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) {