Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dde9a94
Support new payment states
andrii-vysotskyi-cko Aug 11, 2026
b2f891a
Support available payment actions
andrii-vysotskyi-cko Aug 11, 2026
964d0e2
Add available actions to adapter response
andrii-vysotskyi-cko Aug 11, 2026
0273a68
Draft payment finalization
andrii-vysotskyi-cko Aug 11, 2026
fc1dddb
Improve payment state handling
andrii-vysotskyi-cko Aug 12, 2026
07edfbc
Fix incorrect state transition
andrii-vysotskyi-cko Aug 12, 2026
767d8d2
Fix available action decoding
andrii-vysotskyi-cko Aug 12, 2026
f2b5857
Fix assertion message
andrii-vysotskyi-cko Aug 12, 2026
22a5367
Merge remote-tracking branch 'origin/master' into feat/napm-finalization
andrii-vysotskyi-cko Sep 21, 2026
28658cc
Support payment configuration when creating invoce
andrii-vysotskyi-cko Sep 21, 2026
5a1ed6e
Ensure available actions are equatable
andrii-vysotskyi-cko Sep 21, 2026
1938d94
Add finalization mode to example
andrii-vysotskyi-cko Sep 21, 2026
f902a7a
Commit generated changes
andrii-vysotskyi-cko Sep 21, 2026
2823daf
Fix errors
andrii-vysotskyi-cko Sep 22, 2026
b44013a
Fix payment state handling
andrii-vysotskyi-cko Sep 22, 2026
b85c39f
Support payment finalization during dynamic checkout
andrii-vysotskyi-cko Sep 22, 2026
2cac4ca
Improve finalization handling
andrii-vysotskyi-cko Sep 28, 2026
8b49914
Support unexpected redirect error handling
andrii-vysotskyi-cko Sep 28, 2026
727be08
Improve cancellation handling
andrii-vysotskyi-cko Sep 29, 2026
cb9891f
Support creating invoice with metadata
andrii-vysotskyi-cko Oct 16, 2026
b69e873
Merge remote-tracking branch 'origin/master' into feat/napm-finalization
andrii-vysotskyi-cko Oct 16, 2026
6888756
Merge remote-tracking branch 'origin/master' into feat/napm-finalization
andrii-vysotskyi-cko Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Example/Example/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ProcessOut</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
Expand Down Expand Up @@ -47,7 +49,5 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>CFBundleDisplayName</key>
<string>ProcessOut</string>
</dict>
</plist>
10 changes: 10 additions & 0 deletions Example/Example/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
}
}
},
"alternative-payments.finalization-mode" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Finalization Mode"
}
}
}
},
"alternative-payments.flow" : {
"localizations" : {
"en" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation
import Combine
import SwiftUI
import ProcessOut
@_spi(PO) import ProcessOut
import ProcessOutUI

@MainActor
Expand All @@ -19,6 +19,7 @@ final class AlternativePaymentsViewModel: ObservableObject {
cancellables = []
observeInteractorStateChanges()
updateStateFlows()
updateStateFinalizationModes()
}

// MARK: - AlternativePaymentsViewModel
Expand Down Expand Up @@ -133,16 +134,26 @@ 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
}
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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Void, any Error>?, result: Result<Void, POFailure>?
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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Flow, Flow>! // swiftlint:disable:this implicitly_unwrapped_optional
var flow: PickerData<Flow, Flow>!

/// Mode that describes how payment is finalized once all customer actions are completed.
var finalizationMode: PickerData<FinalizationMode, FinalizationMode>!

// swiftlint:enable implicitly_unwrapped_optional

/// Currently presented native alternative payment.
var nativePayment: NativePayment?
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
19 changes: 19 additions & 0 deletions Sources/ProcessOut/Sources/Core/Utils/Task+Cancellation.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<VoidCodable>.post(
path: "/invoices/\(request.invoiceId)/capture", body: requestBox
)
_ = try await connector.execute(request: httpRequest)
}

// MARK: - Deprecated

func nativeAlternativePaymentMethodTransactionDetails(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading