diff --git a/Example/Example/Resources/Localizable.xcstrings b/Example/Example/Resources/Localizable.xcstrings index a25557553..ea3d5d5cd 100644 --- a/Example/Example/Resources/Localizable.xcstrings +++ b/Example/Example/Resources/Localizable.xcstrings @@ -361,6 +361,16 @@ } } }, + "dynamic-checkout.3ds-service" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3DS Service" + } + } + } + }, "dynamic-checkout.error-message" : { "localizations" : { "en" : { diff --git a/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModel.swift b/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModel.swift index 2be31388c..c545eee1b 100644 --- a/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModel.swift +++ b/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModel.swift @@ -18,13 +18,12 @@ final class CardPaymentViewModel: ObservableObject { init(invoicesService: POInvoicesService) { self.invoicesService = invoicesService - commonInit() } // MARK: - @Published - var state: CardPaymentViewModelState! // swiftlint:disable:this implicitly_unwrapped_optional + var state = CardPaymentViewModelState() func pay() { state.message = nil @@ -37,13 +36,6 @@ final class CardPaymentViewModel: ObservableObject { // MARK: - Private Methods - private func commonInit() { - state = .init( - authenticationService: .init(sources: [.test, .checkout, .netcetera], id: \.self, selection: .netcetera), - cardTokenization: nil - ) - } - private func setCardTokenizationItem() { let configuration = POCardTokenizationConfiguration( cardholderName: nil, diff --git a/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModelState.swift b/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModelState.swift index d67b8edf6..47d14791f 100644 --- a/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModelState.swift +++ b/Example/Example/Sources/UI/Modules/CardPayment/ViewModel/CardPaymentViewModelState.swift @@ -41,7 +41,9 @@ struct CardPaymentViewModelState { var invoice = InvoiceViewModel() /// 3DS service. - var authenticationService: PickerData + var authenticationService = PickerData( + sources: [.test, .checkout, .netcetera], id: \.self, selection: .netcetera + ) /// Card tokenization. var cardTokenization: CardTokenization? diff --git a/Example/Example/Sources/UI/Modules/DynamicCheckout/Symbols/LocalizedStringResource+DynamicCheckout.swift b/Example/Example/Sources/UI/Modules/DynamicCheckout/Symbols/LocalizedStringResource+DynamicCheckout.swift index c317cba10..87f082a51 100644 --- a/Example/Example/Sources/UI/Modules/DynamicCheckout/Symbols/LocalizedStringResource+DynamicCheckout.swift +++ b/Example/Example/Sources/UI/Modules/DynamicCheckout/Symbols/LocalizedStringResource+DynamicCheckout.swift @@ -14,6 +14,9 @@ extension LocalizedStringResource { /// Title. static let title = LocalizedStringResource("dynamic-checkout.title") + /// 3DS service. + static let threeDSService = LocalizedStringResource("dynamic-checkout.3ds-service") + /// Continue button. static let pay = LocalizedStringResource("dynamic-checkout.pay") diff --git a/Example/Example/Sources/UI/Modules/DynamicCheckout/View/DynamicCheckoutView.swift b/Example/Example/Sources/UI/Modules/DynamicCheckout/View/DynamicCheckoutView.swift index 724d2b573..e2e79730a 100644 --- a/Example/Example/Sources/UI/Modules/DynamicCheckout/View/DynamicCheckoutView.swift +++ b/Example/Example/Sources/UI/Modules/DynamicCheckout/View/DynamicCheckoutView.swift @@ -16,6 +16,13 @@ struct DynamicCheckoutView: View { MessageView(viewModel: viewModel) } InvoiceView(viewModel: $viewModel.state.invoice) + Section { + Picker(data: $viewModel.state.authenticationService) { service in + Text(service.rawValue.capitalized) + } label: { + Text(.DynamicCheckout.threeDSService) + } + } Button(String(localized: .DynamicCheckout.pay)) { viewModel.pay() } diff --git a/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModel.swift b/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModel.swift index 7054f6354..f243218e7 100644 --- a/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModel.swift +++ b/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModel.swift @@ -9,6 +9,9 @@ import PassKit import SwiftUI @_spi(PO) import ProcessOut @_spi(PO) import ProcessOutUI +import ProcessOutCheckout3DS +import ProcessOutNetcetera3DS +import Checkout3DS @MainActor final class DynamicCheckoutViewModel: ObservableObject { @@ -112,7 +115,16 @@ extension DynamicCheckoutViewModel: PODynamicCheckoutDelegate { willAuthorizeInvoiceWith request: inout POInvoiceAuthorizationRequest, using paymentMethod: PODynamicCheckoutPaymentMethod ) async -> any PO3DS2Service { - POTest3DSService() + switch state.authenticationService.selection { + case .test: + return POTest3DSService() + case .checkout: + let delegate = DefaultCheckout3DSDelegate() + return POCheckout3DSService(delegate: delegate, environment: .sandbox) + case .netcetera: + let configuration = PONetcetera3DS2ServiceConfiguration(returnUrl: Constants.returnUrl) + return PONetcetera3DS2Service(configuration: configuration) + } } func dynamicCheckout(willAuthorizeInvoiceWith request: PKPaymentRequest) async { diff --git a/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelState.swift b/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelState.swift index 54fb9a392..a82948aa9 100644 --- a/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelState.swift +++ b/Example/Example/Sources/UI/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelState.swift @@ -11,6 +11,18 @@ import ProcessOut struct DynamicCheckoutViewModelState { + enum AuthenticationService: String, Hashable { + + /// Test service. + case test + + /// Checkout service. + case checkout + + /// Netcetera 3DS SDK. + case netcetera + } + struct DynamicCheckout: Identifiable { let id: String @@ -28,6 +40,11 @@ struct DynamicCheckoutViewModelState { /// Invoice details. var invoice = InvoiceViewModel() + /// 3DS service. + var authenticationService = PickerData( + sources: [.test, .checkout, .netcetera], id: \.self, selection: .netcetera + ) + /// Dynamic checkout. var dynamicCheckout: DynamicCheckout? diff --git a/Sources/ProcessOut/Sources/Connectors/Http/Implementations/UrlSession/UrlSessionHttpConnector.swift b/Sources/ProcessOut/Sources/Connectors/Http/Implementations/UrlSession/UrlSessionHttpConnector.swift index b4f51acea..13b650ae0 100644 --- a/Sources/ProcessOut/Sources/Connectors/Http/Implementations/UrlSession/UrlSessionHttpConnector.swift +++ b/Sources/ProcessOut/Sources/Connectors/Http/Implementations/UrlSession/UrlSessionHttpConnector.swift @@ -87,9 +87,22 @@ final class UrlSessionHttpConnector: HttpConnector { private func convertToFailure(urlError error: Error) -> Failure { let code: Failure.Code switch error { + case URLError.appTransportSecurityRequiresSecureConnection, + URLError.secureConnectionFailed, + URLError.serverCertificateHasBadDate, + URLError.serverCertificateHasUnknownRoot, + URLError.serverCertificateNotYetValid, + URLError.serverCertificateUntrusted, + URLError.clientCertificateRejected, + URLError.clientCertificateRequired: + code = .security case URLError.cancelled: code = .cancelled - case URLError.notConnectedToInternet, URLError.networkConnectionLost: + case URLError.notConnectedToInternet, + URLError.networkConnectionLost, + URLError.internationalRoamingOff, + URLError.dataNotAllowed, + URLError.callIsActive: code = .networkUnreachable case URLError.timedOut: code = .timeout diff --git a/Sources/ProcessOut/Sources/Connectors/Http/Models/HttpConnectorFailure.swift b/Sources/ProcessOut/Sources/Connectors/Http/Models/HttpConnectorFailure.swift index 8d62986f4..0490d8f96 100644 --- a/Sources/ProcessOut/Sources/Connectors/Http/Models/HttpConnectorFailure.swift +++ b/Sources/ProcessOut/Sources/Connectors/Http/Models/HttpConnectorFailure.swift @@ -27,6 +27,9 @@ struct HttpConnectorFailure: Error, Sendable { /// Cancellation error. case cancelled + /// An attempt to establish a secure connection failed. + case security + /// Internal error. case `internal` } diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Decorators/HttpConnectorError/FailureMapper/DefaultHttpConnectorFailureMapper.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Decorators/HttpConnectorError/FailureMapper/DefaultHttpConnectorFailureMapper.swift index c461db086..508152422 100644 --- a/Sources/ProcessOut/Sources/Repositories/Shared/Decorators/HttpConnectorError/FailureMapper/DefaultHttpConnectorFailureMapper.swift +++ b/Sources/ProcessOut/Sources/Repositories/Shared/Decorators/HttpConnectorError/FailureMapper/DefaultHttpConnectorFailureMapper.swift @@ -34,6 +34,10 @@ final class DefaultHttpConnectorFailureMapper: HttpConnectorFailureMapper { message = "Request was cancelled." code = .Mobile.cancelled invalidFields = nil + case .security: + message = "An attempt to establish a secure connection failed." + code = .Mobile.connectionSecurity + invalidFields = nil case let .server(error, _): message = error.message code = .init(rawValue: error.errorType) diff --git a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift index b61e69c6a..f4771cb3a 100644 --- a/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift +++ b/Sources/ProcessOut/Sources/Repositories/Shared/Responses/Failure/POFailureCode.swift @@ -506,6 +506,9 @@ extension POFailureCode { /// Cancellation error. public static let cancelled = POFailureCode(rawValue: "processout-mobile.cancelled") + /// An attempt to establish a secure connection failed. + public static let connectionSecurity = POFailureCode(rawValue: "processout-mobile.connection-security") + /// Internal error. public static let `internal` = POFailureCode(rawValue: "processout-mobile.internal") } diff --git a/Sources/ProcessOut/Sources/Services/AlternativePayments/DefaultAlternativePaymentsService.swift b/Sources/ProcessOut/Sources/Services/AlternativePayments/DefaultAlternativePaymentsService.swift index 1adef235a..b7d43e5b8 100644 --- a/Sources/ProcessOut/Sources/Services/AlternativePayments/DefaultAlternativePaymentsService.swift +++ b/Sources/ProcessOut/Sources/Services/AlternativePayments/DefaultAlternativePaymentsService.swift @@ -101,18 +101,23 @@ final class DefaultAlternativePaymentsService: POAlternativePaymentsService { throw POFailure(message: message, code: .generic(.mobile), underlyingError: nil) } let queryItems = components.queryItems ?? [] + if let gatewayToken = queryItems.queryItemValue(name: "token") { + if gatewayToken.isEmpty { + logger.debug("Gateway 'token' is empty in \(url), this may be an error.") + } + let tokenId = queryItems.queryItemValue(name: "token_id") + if let customerId = queryItems.queryItemValue(name: "customer_id"), let tokenId { + return .init( + gatewayToken: gatewayToken, customerId: customerId, tokenId: tokenId, returnType: .createToken + ) + } + return .init(gatewayToken: gatewayToken, customerId: nil, tokenId: tokenId, returnType: .authorization) + } if let errorCode = queryItems.queryItemValue(name: "error_code") { throw POFailure(code: .init(rawValue: errorCode)) } - let gatewayToken = queryItems.queryItemValue(name: "token") ?? "" - if gatewayToken.isEmpty { - logger.debug("Gateway 'token' is not set in \(url), this may be an error.") - } - let tokenId = queryItems.queryItemValue(name: "token_id") - if let customerId = queryItems.queryItemValue(name: "customer_id"), let tokenId { - return .init(gatewayToken: gatewayToken, customerId: customerId, tokenId: tokenId, returnType: .createToken) - } - return .init(gatewayToken: gatewayToken, customerId: nil, tokenId: tokenId, returnType: .authorization) + logger.warn("Both token and error_code are not set in \(url).") + return .init(gatewayToken: "", customerId: nil, tokenId: nil, returnType: .authorization) } // MARK: - @@ -154,14 +159,17 @@ final class DefaultAlternativePaymentsService: POAlternativePaymentsService { throw POFailure(message: message, code: .Mobile.generic, underlyingError: nil) } let queryItems = components.queryItems ?? [] + if let gatewayToken = queryItems.queryItemValue(name: "token") { + if gatewayToken.isEmpty { + logger.debug("Gateway 'token' is empty in \(url), this may be an error.") + } + return .init(gatewayToken: gatewayToken) + } if let errorCode = queryItems.queryItemValue(name: "error_code") { throw POFailure(code: .init(rawValue: errorCode)) } - let gatewayToken = queryItems.queryItemValue(name: "token") ?? "" - if gatewayToken.isEmpty { - logger.debug("Gateway 'token' is not set in \(url), this may be an error.") - } - return .init(gatewayToken: gatewayToken) + logger.warn("Both token and error_code are not set in \(url).") + return .init(gatewayToken: "") } } diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index ed5cacbbb..4dd97a80a 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -599,14 +599,16 @@ final class NativeAlternativePaymentDefaultInteractor: ) async throws -> NativeAlternativePaymentResolvedElement.Group { var resolvedInstructions: [NativeAlternativePaymentResolvedElement.Instruction] = [] for instruction in group.instructions { - resolvedInstructions.append(try await resolve(customerInstruction: instruction)) + if let resolvedInstruction = try await resolve(customerInstruction: instruction) { + resolvedInstructions.append(resolvedInstruction) + } } return .init(label: group.label, instructions: resolvedInstructions) } private func resolve( customerInstruction: PONativeAlternativePaymentCustomerInstructionV2 - ) async throws -> NativeAlternativePaymentResolvedElement.Instruction { + ) async throws -> NativeAlternativePaymentResolvedElement.Instruction? { switch customerInstruction { case .barcode(let barcode): let minimumSize = CGSize(width: 250, height: 250) @@ -618,8 +620,9 @@ final class NativeAlternativePaymentDefaultInteractor: case .message(let message): return .message(.init(label: message.label, value: message.value)) case .image(let image): - guard let image = await imagesRepository.image(resource: image.value) else { - throw POFailure(message: "Unable to prepare customer instruction image.", code: .Mobile.internal) + guard let image = await imagesRepository.image(resource: image.value) else { // Treated as decoration + logger.debug("Unable to prepare customer instruction image.") + return nil } return .image(image) default: @@ -653,12 +656,15 @@ final class NativeAlternativePaymentDefaultInteractor: } return .form(form) case .customerInstruction(let instruction): - return .instruction(try await resolve(customerInstruction: instruction)) + if let resolvedInstruction = try await resolve(customerInstruction: instruction) { + return .instruction(resolvedInstruction) + } case .group(let group): return .group(try await resolve(group: group)) case .unknown: return nil } + return nil } // MARK: - Payment Method Utils