Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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