From 479ec042b8adac6507b9a270b9c0fdb608cbdf23 Mon Sep 17 00:00:00 2001 From: Andrii Vysotskyi Date: Fri, 12 Jul 2024 15:31:39 +0200 Subject: [PATCH] feat(POM0-395): improve dynamic checkout failure recovery (#308) * Allow recovering error within screen * Allow changing native alternative payment methods * Fix POBrandButtonStyle brand color brightness calculation --- .../ViewModel/FeaturesViewModel.swift | 11 + .../PODynamicCheckoutPaymentMethod.swift | 44 ++-- .../Surface/Neutral.colorset/Contents.json | 38 --- .../ButtonStyle+POBrandButtonStyle.swift | 2 +- .../Button/Brand/POBrandButtonStyle.swift | 23 +- .../Button/Brand/View+BrandColor.swift | 6 +- .../ResourceSymbols/ColorResource.swift | 3 - .../Resources/Localizable.xcstrings | 68 ----- .../Delegate/PODynamicCheckoutDelegate.swift | 12 +- ...namicCheckoutInteractorChildProvider.swift | 6 +- ...eckoutInteractorDefaultChildProvider.swift | 22 +- .../DynamicCheckoutDefaultInteractor.swift | 244 ++++++++++-------- .../DynamicCheckoutInteractorState.swift | 35 ++- ...CheckoutPassKitPaymentDefaultSession.swift | 20 +- ...DynamicCheckoutPassKitPaymentSession.swift | 2 +- .../PODynamicCheckoutStyle+Default.swift | 3 +- .../Style/PODynamicCheckoutStyle.swift | 11 +- .../StringResource+DynamicCheckout.swift | 6 - ...ynamicCheckoutRegularPaymentInfoView.swift | 1 - .../DynamicCheckoutRegularPaymentView.swift | 2 + .../View/DynamicCheckoutSectionView.swift | 17 +- .../View/PODynamicCheckoutView+Init.swift | 5 +- .../DefaultDynamicCheckoutViewModel.swift | 98 +++++-- .../DynamicCheckoutViewModelItem.swift | 6 +- 24 files changed, 363 insertions(+), 322 deletions(-) delete mode 100644 Sources/ProcessOutCoreUI/Resources/Colors.xcassets/Surface/Neutral.colorset/Contents.json diff --git a/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift b/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift index e67d37694..52a94ec5f 100644 --- a/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift +++ b/Example/Example/Sources/UI/Modules/Features/ViewModel/FeaturesViewModel.swift @@ -156,6 +156,17 @@ extension FeaturesViewModel: PODynamicCheckoutDelegate { .init(label: "Test", amount: 100, type: .final) ] } + + func dynamicCheckout(newInvoiceFor invoice: POInvoice) async -> POInvoice? { + let request = POInvoiceCreationRequest( + name: "Example", + amount: invoice.amount.description, + currency: invoice.currency, + returnUrl: invoice.returnUrl, + customerId: Constants.customerId + ) + return try? await invoicesService.createInvoice(request: request) + } } extension FeaturesViewModel: POPassKitPaymentAuthorizationControllerDelegate { diff --git a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/PODynamicCheckoutPaymentMethod.swift b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/PODynamicCheckoutPaymentMethod.swift index 94e1b5a6c..c1dbd13be 100644 --- a/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/PODynamicCheckoutPaymentMethod.swift +++ b/Sources/ProcessOut/Sources/Repositories/Invoices/Responses/PODynamicCheckoutPaymentMethod.swift @@ -17,9 +17,11 @@ public enum PODynamicCheckoutPaymentMethod { public struct ApplePay: Decodable { // sourcery: AutoCodingKeys - /// Transient ID assigned to method during decoding. + /// Payment method ID. @_spi(PO) - public let id = UUID().uuidString // sourcery:coding: skip + public var id: String { // sourcery:coding: skip + configuration.merchantId + } /// Payment flow. public let flow: Flow? @@ -48,9 +50,11 @@ public enum PODynamicCheckoutPaymentMethod { public struct NativeAlternativePayment: Decodable { // sourcery: AutoCodingKeys - /// Transient ID assigned to method during decoding. + /// Payment method ID. @_spi(PO) - public let id = UUID().uuidString // sourcery:coding: skip + public var id: String { // sourcery:coding: skip + configuration.gatewayConfigurationId + } /// Display information. public let display: Display @@ -69,9 +73,11 @@ public enum PODynamicCheckoutPaymentMethod { public struct AlternativePayment: Decodable { // sourcery: AutoCodingKeys - /// Transient ID assigned to method during decoding. + /// Payment method ID. @_spi(PO) - public let id = UUID().uuidString // sourcery:coding: skip + public var id: String { // sourcery:coding: skip + configuration.gatewayConfigurationId + } /// Display information. public let display: Display @@ -85,6 +91,9 @@ public enum PODynamicCheckoutPaymentMethod { public struct AlternativePaymentConfiguration: Decodable { + /// Gateway configuration ID. + public let gatewayConfigurationId: String + /// Redirect URL. public let redirectUrl: URL } @@ -93,9 +102,9 @@ public enum PODynamicCheckoutPaymentMethod { public struct Card: Decodable { // sourcery: AutoCodingKeys - /// Transient ID assigned to method during decoding. + /// Payment method ID. @_spi(PO) - public let id = UUID().uuidString // sourcery:coding: skip + public let id = "card" // sourcery:coding: skip /// Display information. public let display: Display @@ -119,6 +128,15 @@ public enum PODynamicCheckoutPaymentMethod { public let billingAddress: BillingAddressConfiguration } + public struct BillingAddressConfiguration: Decodable { + + /// List of ISO country codes that is supported for the billing address. When nil, all countries are supported. + public let restrictToCountryCodes: Set? + + /// Billing address collection mode. + public let collectionMode: POBillingAddressCollectionMode + } + // MARK: - Unknown public struct Unknown { @@ -145,15 +163,6 @@ public enum PODynamicCheckoutPaymentMethod { public private(set) var brandColor: UIColor } - public struct BillingAddressConfiguration: Decodable { - - /// List of ISO country codes that is supported for the billing address. When nil, all countries are supported. - public let restrictToCountryCodes: Set? - - /// Billing address collection mode. - public let collectionMode: POBillingAddressCollectionMode - } - public enum Flow: String, Decodable { case express } @@ -218,6 +227,7 @@ extension PODynamicCheckoutPaymentMethod { case .card(let method): return method.id case .unknown(let method): + assertionFailure("It is considered an error to request an ID for unknown payment method.") return method.id } } diff --git a/Sources/ProcessOutCoreUI/Resources/Colors.xcassets/Surface/Neutral.colorset/Contents.json b/Sources/ProcessOutCoreUI/Resources/Colors.xcassets/Surface/Neutral.colorset/Contents.json deleted file mode 100644 index adc7ff872..000000000 --- a/Sources/ProcessOutCoreUI/Resources/Colors.xcassets/Surface/Neutral.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xFA", - "green" : "0xFA", - "red" : "0xFA" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x38", - "green" : "0x2C", - "red" : "0x24" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/ButtonStyle+POBrandButtonStyle.swift b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/ButtonStyle+POBrandButtonStyle.swift index cd5b08924..6b4132b0e 100644 --- a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/ButtonStyle+POBrandButtonStyle.swift +++ b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/ButtonStyle+POBrandButtonStyle.swift @@ -13,6 +13,6 @@ extension ButtonStyle where Self == POBrandButtonStyle { /// Simple style that changes its appearance based on brand color ``SwiftUI/EnvironmentValues/poButtonBrandColor``. @_disfavoredOverload public static var brand: POBrandButtonStyle { - .init(title: .init(color: Color(.Text.inverse), typography: .button), border: .clear, shadow: .clear) + .init(title: .init(color: Color(.Text.primary), typography: .button), border: .clear, shadow: .clear) } } diff --git a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/POBrandButtonStyle.swift b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/POBrandButtonStyle.swift index a0bbda3f1..6d3d96ba1 100644 --- a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/POBrandButtonStyle.swift +++ b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/POBrandButtonStyle.swift @@ -9,6 +9,8 @@ import SwiftUI /// Brand button style that only requires base style information and resolves /// styling for other states automatically. +/// +/// - NOTE: SDK uses light color variation with light brand colors and dark otherwise. @available(iOS 14, *) public struct POBrandButtonStyle: ButtonStyle { @@ -36,7 +38,6 @@ public struct POBrandButtonStyle: ButtonStyle { let isBrandColorLight = UIColor(brandColor).isLight() != false configuration.label .textStyle(title) - .colorScheme(isBrandColorLight ? .light : .dark) .lineLimit(1) .padding(Constants.padding) .frame(maxWidth: .infinity, minHeight: Constants.minHeight) @@ -48,6 +49,7 @@ public struct POBrandButtonStyle: ButtonStyle { } .border(style: border) .shadow(style: shadow) + .colorScheme(isBrandColorLight ? .light : .dark) .contentShape(.rect) .animation(.default, value: isEnabled) .animation(.default, value: AnyHashable(brandColor)) @@ -75,6 +77,7 @@ public struct POBrandButtonStyle: ButtonStyle { // Environments are not propagated directly to ButtonStyle in any iOS before 14.5 workaround is // to wrap content into additional view and extract them. +@available(iOS 14, *) private struct ContentView: View { @ViewBuilder @@ -86,6 +89,20 @@ private struct ContentView: View { // MARK: - Private Properties - @Environment(\.isEnabled) private var isEnabled - @Environment(\.poButtonBrandColor) private var brandColor + @Environment(\.isEnabled) + private var isEnabled + + @Environment(\.poButtonBrandColor) + private var uiBrandColor + + @Environment(\.colorScheme) + private var colorScheme + + // MARK: - Private Methods + + private var brandColor: Color { + let interfaceStyle = UIUserInterfaceStyle(colorScheme) + let traitCollection = UITraitCollection(userInterfaceStyle: interfaceStyle) + return Color(uiBrandColor.resolvedColor(with: traitCollection)) + } } diff --git a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/View+BrandColor.swift b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/View+BrandColor.swift index a71c69e20..ac7ca960e 100644 --- a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/View+BrandColor.swift +++ b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Brand/View+BrandColor.swift @@ -10,7 +10,7 @@ import SwiftUI extension View { /// Adds a condition that controls whether button with a `POButtonStyle` should show loading indicator. - @_spi(PO) public func buttonBrandColor(_ color: Color) -> some View { + @_spi(PO) public func buttonBrandColor(_ color: UIColor) -> some View { environment(\.poButtonBrandColor, color) } } @@ -18,7 +18,7 @@ extension View { extension EnvironmentValues { /// Brand color of associated third party service. Only ``POBrandButtonStyle`` responds to this property changes. - public var poButtonBrandColor: Color { + public var poButtonBrandColor: UIColor { get { self[Key.self] } set { self[Key.self] = newValue } } @@ -26,6 +26,6 @@ extension EnvironmentValues { // MARK: - Private Nested Types private struct Key: EnvironmentKey { - static let defaultValue = Color(.Button.Primary.Background.default) + static let defaultValue = UIColor(resource: .Button.Primary.Background.default) } } diff --git a/Sources/ProcessOutCoreUI/Sources/ResourceSymbols/ColorResource.swift b/Sources/ProcessOutCoreUI/Sources/ResourceSymbols/ColorResource.swift index 82d8788a2..a6a6113e3 100644 --- a/Sources/ProcessOutCoreUI/Sources/ResourceSymbols/ColorResource.swift +++ b/Sources/ProcessOutCoreUI/Sources/ResourceSymbols/ColorResource.swift @@ -35,9 +35,6 @@ extension POColorResource { /// The "Surface/Default" asset catalog color resource. public static let `default` = POColorResource(.Surface.default) - /// The "Surface/Neutral" asset catalog color resource. - public static let neutral = POColorResource(.Surface.neutral) - /// The "Surface/Success" asset catalog color resource. public static let success = POColorResource(.Surface.success) diff --git a/Sources/ProcessOutUI/Resources/Localizable.xcstrings b/Sources/ProcessOutUI/Resources/Localizable.xcstrings index c169d074a..e69bac3e0 100644 --- a/Sources/ProcessOutUI/Resources/Localizable.xcstrings +++ b/Sources/ProcessOutUI/Resources/Localizable.xcstrings @@ -1633,40 +1633,6 @@ } } }, - "dynamic-checkout.error.unavailable-method" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "طريقة الدفع المطلوبة غير متاحة، يرجى تجربة طريقة دفع أخرى." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The requested payment method is not available, please try another payment method." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le mode de paiement demandé n'est pas disponible, veuillez essayer un autre mode de paiement." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybrana metoda płatności nie jest obsługiwana. Proszę wybierz inną metodę." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "O método de pagamento escolhido não está disponível, por favor tente outro método de pagamento." - } - } - } - }, "dynamic-checkout.pay-button" : { "localizations" : { "ar" : { @@ -1769,40 +1735,6 @@ } } }, - "dynamic-checkout.unavailable-warning" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "طريقة الدفع هذه غير متاحة." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This payment method is unavailable." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ce mode de paiement est indisponible." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ta metoda płatności nie jest obsługiwana." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Este método de pagamento não está disponível." - } - } - } - }, "native-alternative-payment.cancel-button.title" : { "localizations" : { "ar" : { diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift index 438dd2e5e..7307fbdd0 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Delegate/PODynamicCheckoutDelegate.swift @@ -20,12 +20,18 @@ public protocol PODynamicCheckoutDelegate: AnyObject { /// /// Your implementation may alter request parameters and return new request but make /// sure that invoice id and source stay the same. - func dynamicCheckout(willAuthorizeInvoiceWith request: inout POInvoiceAuthorizationRequest) async -> PO3DSService + func dynamicCheckout( + willAuthorizeInvoiceWith request: inout POInvoiceAuthorizationRequest + ) async -> PO3DSService /// Asks delegate whether user should be allowed to continue after failure or module should complete. /// Default implementation returns `true`. func dynamicCheckout(shouldContinueAfter failure: POFailure) -> Bool + /// Your implementation could return new invoice to replace existing one to be able to recover from + /// normally unrecoverable payment failure. + func dynamicCheckout(newInvoiceFor invoice: POInvoice) async -> POInvoice? + // MARK: - Card Payment /// Invoked when module emits event. @@ -64,6 +70,10 @@ extension PODynamicCheckoutDelegate { true } + public func dynamicCheckout(newInvoiceFor invoice: POInvoice) async -> POInvoice? { + nil + } + public func dynamicCheckout(didEmitCardTokenizationEvent event: POCardTokenizationEvent) { // Ignored } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorChildProvider.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorChildProvider.swift index 66f54b858..15a470d1b 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorChildProvider.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorChildProvider.swift @@ -13,9 +13,11 @@ protocol DynamicCheckoutInteractorChildProvider { /// Creates and returns card tokenization interactor. func cardTokenizationInteractor( - configuration: PODynamicCheckoutPaymentMethod.CardConfiguration + invoiceId: String, configuration: PODynamicCheckoutPaymentMethod.CardConfiguration ) -> any CardTokenizationInteractor /// Creates and returns native APM interactor.. - func nativeAlternativePaymentInteractor(gatewayConfigurationId: String) -> any NativeAlternativePaymentInteractor + func nativeAlternativePaymentInteractor( + invoiceId: String, gatewayConfigurationId: String + ) -> any NativeAlternativePaymentInteractor } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift index b53ca208e..22ae373d8 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift @@ -27,10 +27,10 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera // MARK: - DynamicCheckoutInteractorChildProvider func cardTokenizationInteractor( - configuration: PODynamicCheckoutPaymentMethod.CardConfiguration + invoiceId: String, configuration: PODynamicCheckoutPaymentMethod.CardConfiguration ) -> any CardTokenizationInteractor { var logger = logger - logger[attributeKey: .invoiceId] = self.configuration.invoiceId + logger[attributeKey: .invoiceId] = invoiceId let interactor = DefaultCardTokenizationInteractor( cardsService: cardsService, logger: logger, @@ -40,12 +40,16 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera return interactor } - func nativeAlternativePaymentInteractor(gatewayConfigurationId: String) -> any NativeAlternativePaymentInteractor { + func nativeAlternativePaymentInteractor( + invoiceId: String, gatewayConfigurationId: String + ) -> any NativeAlternativePaymentInteractor { var logger = self.logger - logger[attributeKey: .invoiceId] = configuration.invoiceId + logger[attributeKey: .invoiceId] = invoiceId logger[attributeKey: .gatewayConfigurationId] = gatewayConfigurationId let interactor = NativeAlternativePaymentDefaultInteractor( - configuration: alternativePaymentConfiguration(gatewayId: gatewayConfigurationId), + configuration: alternativePaymentConfiguration( + invoiceId: invoiceId, gatewayConfigurationId: gatewayConfigurationId + ), invoicesService: invoicesService, imagesRepository: imagesRepository, logger: logger, @@ -85,7 +89,9 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera return cardConfiguration } - private func alternativePaymentConfiguration(gatewayId: String) -> PONativeAlternativePaymentConfiguration { + private func alternativePaymentConfiguration( + invoiceId: String, gatewayConfigurationId: String + ) -> PONativeAlternativePaymentConfiguration { let confirmationConfiguration = PONativeAlternativePaymentConfirmationConfiguration( waitsConfirmation: true, timeout: configuration.alternativePayment.paymentConfirmation.timeout, @@ -95,8 +101,8 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera } ) let alternativePaymentConfiguration = PONativeAlternativePaymentConfiguration( - invoiceId: configuration.invoiceId, - gatewayConfigurationId: gatewayId, + invoiceId: invoiceId, + gatewayConfigurationId: gatewayConfigurationId, title: "", shouldHorizontallyCenterCodeInput: false, successMessage: "", diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutDefaultInteractor.swift index 19b301be0..ce327eee1 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutDefaultInteractor.swift @@ -66,14 +66,14 @@ final class DynamicCheckoutDefaultInteractor: guard currentState.paymentMethodId != methodId else { return } - guard !currentState.snapshot.unavailablePaymentMethodIds.contains(methodId) else { - logger.debug("Ignoring unavailable method selection", attributes: ["MethodId": methodId]) - return - } currentState.pendingPaymentMethodId = methodId currentState.shouldStartPendingPaymentMethod = false state = .paymentProcessing(currentState) cancel(force: false) + case .recovering(var currentState): + currentState.pendingPaymentMethodId = methodId + currentState.shouldStartPendingPaymentMethod = false + state = .recovering(currentState) default: logger.debug("Unable to change selection in unsupported state: \(state)") } @@ -90,14 +90,14 @@ final class DynamicCheckoutDefaultInteractor: guard currentState.paymentMethodId != methodId else { return } - guard !currentState.snapshot.unavailablePaymentMethodIds.contains(methodId) else { - logger.debug("Ignoring unavailable method selection", attributes: ["MethodId": methodId]) - return - } currentState.pendingPaymentMethodId = methodId currentState.shouldStartPendingPaymentMethod = true state = .paymentProcessing(currentState) cancel(force: false) + case .recovering(var currentState): + currentState.pendingPaymentMethodId = methodId + currentState.shouldStartPendingPaymentMethod = true + state = .recovering(currentState) default: logger.debug("Unable to start payment in unsupported state: \(state)") } @@ -141,23 +141,25 @@ final class DynamicCheckoutDefaultInteractor: private let alternativePaymentSession: DynamicCheckoutAlternativePaymentSession private let childProvider: DynamicCheckoutInteractorChildProvider private let invoicesService: POInvoicesService - private let logger: POLogger private let completion: (Result) -> Void + private var logger: POLogger private weak var delegate: PODynamicCheckoutDelegate? // MARK: - Starting State @MainActor private func continueStartUnchecked() async { - let invoice: POInvoice do { let request = POInvoiceRequest(id: configuration.invoiceId) - invoice = try await invoicesService.invoice(request: request) + let invoice = try await invoicesService.invoice(request: request) + setStartedStateUnchecked(invoice: invoice) } catch { setFailureStateUnchecked(error: error) - return } + } + + private func setStartedStateUnchecked(invoice: POInvoice, errorDescription: String? = nil) { let pkPaymentRequests = pkPaymentRequests(invoice: invoice) var expressMethodIds: [String] = [], regularMethodIds: [String] = [] let paymentMethods = partitioned( @@ -171,10 +173,13 @@ final class DynamicCheckoutDefaultInteractor: expressPaymentMethodIds: expressMethodIds, regularPaymentMethodIds: regularMethodIds, pkPaymentRequests: pkPaymentRequests, - isCancellable: configuration.cancelButton?.title.map { !$0.isEmpty } ?? true + isCancellable: configuration.cancelButton?.title.map { !$0.isEmpty } ?? true, + invoice: invoice, + recentErrorDescription: errorDescription ) state = .started(startedState) send(event: .didStart) + logger[attributeKey: .invoiceId] = invoice.id logger.debug("Did start dynamic checkout flow") initiateDefaultPaymentIfNeeded() } @@ -280,59 +285,39 @@ final class DynamicCheckoutDefaultInteractor: interactor.cancel() case .started, .selected: setFailureStateUnchecked(error: POFailure(code: .cancelled)) + case .recovering: + logger.debug("Ignoring attempt to cancel payment during error recovery.") default: assertionFailure("Attempted to cancel payment from unsupported state.") } } - // MARK: - Started State - - private func setStartedStateAfterSelectionFailure(startedState: State.Started) { - var newState = startedState - newState.unavailablePaymentMethodIds.formUnion(startedState.pendingUnavailablePaymentMethodIds) - newState.pendingUnavailablePaymentMethodIds = [] - newState.recentErrorDescription = String(resource: .DynamicCheckout.Error.unavailableMethod) - state = .started(newState) - } - // MARK: - Selected State private func setSelectedStateUnchecked(methodId: String, startedState: State.Started) { _ = paymentMethod(withId: methodId, state: startedState) - if startedState.pendingUnavailablePaymentMethodIds.contains(methodId) { - setStartedStateAfterSelectionFailure(startedState: startedState) - } else if !startedState.unavailablePaymentMethodIds.contains(methodId) { - var newStartedState = startedState - newStartedState.recentErrorDescription = nil - let newState = State.Selected(snapshot: newStartedState, paymentMethodId: methodId) - state = .selected(newState) - } else { - logger.debug("Ignoring attempt to select unavailable payment method") - } + var newStartedState = startedState + newStartedState.recentErrorDescription = nil + let newState = State.Selected(snapshot: newStartedState, paymentMethodId: methodId) + state = .selected(newState) } // MARK: - Payment Processing private func setPaymentProcessingUnchecked(methodId: String, startedState: State.Started) { - if startedState.pendingUnavailablePaymentMethodIds.contains(methodId) { - setStartedStateAfterSelectionFailure(startedState: startedState) - } else if !startedState.unavailablePaymentMethodIds.contains(methodId) { - var newStartedState = startedState - newStartedState.recentErrorDescription = nil - switch paymentMethod(withId: methodId, state: startedState) { - case .applePay: - startPassKitPayment(methodId: methodId, startedState: newStartedState) - case .card(let method): - startCardPayment(method: method, startedState: newStartedState) - case .alternativePayment(let method): - startAlternativePayment(method: method, startedState: newStartedState) - case .nativeAlternativePayment(let method): - startNativeAlternativePayment(method: method, startedState: newStartedState) - default: - preconditionFailure("Attempted to start unknown payment method") - } - } else { - logger.debug("Ignoring attempt to select unavailable payment method") + var newStartedState = startedState + newStartedState.recentErrorDescription = nil + switch paymentMethod(withId: methodId, state: startedState) { + case .applePay: + startPassKitPayment(methodId: methodId, startedState: newStartedState) + case .card(let method): + startCardPayment(method: method, startedState: newStartedState) + case .alternativePayment(let method): + startAlternativePayment(method: method, startedState: newStartedState) + case .nativeAlternativePayment(let method): + startNativeAlternativePayment(method: method, startedState: newStartedState) + default: + preconditionFailure("Attempted to start unknown payment method") } } @@ -354,10 +339,10 @@ final class DynamicCheckoutDefaultInteractor: state = .paymentProcessing(paymentProcessingState) Task { @MainActor in do { - try await passKitPaymentSession.start(request: request) + try await passKitPaymentSession.start(invoiceId: startedState.invoice.id, request: request) setSuccessState() } catch { - restoreStateAfterPaymentProcessingFailureIfPossible(error) + recoverPaymentProcessing(error: error) } } } @@ -365,7 +350,9 @@ final class DynamicCheckoutDefaultInteractor: // MARK: - Card Payment private func startCardPayment(method: PODynamicCheckoutPaymentMethod.Card, startedState: State.Started) { - let interactor = childProvider.cardTokenizationInteractor(configuration: method.configuration) + let interactor = childProvider.cardTokenizationInteractor( + invoiceId: startedState.invoice.id, configuration: method.configuration + ) interactor.delegate = self interactor.willChange = { [weak self] state in self?.cardTokenization(willChangeState: state) @@ -402,7 +389,7 @@ final class DynamicCheckoutDefaultInteractor: case .tokenized: setSuccessState() case .failure(let failure): - restoreStateAfterPaymentProcessingFailureIfPossible(failure) + recoverPaymentProcessing(error: failure) } } @@ -433,7 +420,7 @@ final class DynamicCheckoutDefaultInteractor: _ = try await alternativePaymentSession.start(url: method.configuration.redirectUrl) setSuccessState() } catch { - self.restoreStateAfterPaymentProcessingFailureIfPossible(error) + self.recoverPaymentProcessing(error: error) } } } @@ -444,6 +431,7 @@ final class DynamicCheckoutDefaultInteractor: method: PODynamicCheckoutPaymentMethod.NativeAlternativePayment, startedState: State.Started ) { let interactor = childProvider.nativeAlternativePaymentInteractor( + invoiceId: startedState.invoice.id, gatewayConfigurationId: method.configuration.gatewayConfigurationId ) interactor.delegate = self @@ -499,13 +487,13 @@ final class DynamicCheckoutDefaultInteractor: case .submitted, .captured: setSuccessState() case .failure(let failure): - restoreStateAfterPaymentProcessingFailureIfPossible(failure) + recoverPaymentProcessing(error: failure) } } - // MARK: - Started State Restoration + // MARK: - Failure Recovery - private func restoreStateAfterPaymentProcessingFailureIfPossible(_ error: Error) { + private func recoverPaymentProcessing(error: Error) { logger.info("Did fail to process payment: \(error)") guard case .paymentProcessing(let currentState) = state else { logger.debug("Failures are expected only when processing payment, aborted") @@ -516,47 +504,92 @@ final class DynamicCheckoutDefaultInteractor: setFailureStateUnchecked(error: error) return } - if case .cancelled = failure.code { - if currentState.isForcelyCancelled { - setFailureStateUnchecked(error: error) - } else if let methodId = currentState.pendingPaymentMethodId { - self.state = .started(currentState.snapshot) - if currentState.shouldStartPendingPaymentMethod { - startPayment(methodId: methodId) - } else { - select(methodId: methodId) - } - } else { - self.state = .started(currentState.snapshot) + if shouldRecover(after: failure, in: currentState) { + Task { + await continuePaymentProcessingRecovery(after: failure) } - } else if delegate?.dynamicCheckout(shouldContinueAfter: failure) != false { - restoreStartedStateUnchecked(after: failure, currentState: currentState) } else { setFailureStateUnchecked(error: failure) } } - private func restoreStartedStateUnchecked(after failure: POFailure, currentState: State.PaymentProcessing) { - var newStartedState = currentState.snapshot - switch currentPaymentMethod(state: currentState) { - case .nativeAlternativePayment: - var pendingUnavailableIds = paymentMethodIds(of: .nativeAlternativePayment, state: currentState.snapshot) - if currentState.isReady { - pendingUnavailableIds.remove(currentState.paymentMethodId) - newStartedState.pendingUnavailablePaymentMethodIds.formUnion(pendingUnavailableIds) - newStartedState.unavailablePaymentMethodIds.insert(currentState.paymentMethodId) + private func shouldRecover(after failure: POFailure, in state: State.PaymentProcessing) -> Bool { + if case .cancelled = failure.code { + return !state.isForcelyCancelled + } + guard let delegate else { + return true // Errors are recovered by default + } + return delegate.dynamicCheckout(shouldContinueAfter: failure) + } + + @MainActor + private func continuePaymentProcessingRecovery(after failure: POFailure) async { + guard case .paymentProcessing(let currentState) = state else { + assertionFailure("Error could be recovered only when processing payment.") + return + } + let recoveringState = State.Recovering( + failure: failure, + snapshot: currentState.snapshot, + failedPaymentMethodId: currentState.paymentMethodId, + pendingPaymentMethodId: currentState.pendingPaymentMethodId, + shouldStartPendingPaymentMethod: currentState.shouldStartPendingPaymentMethod + ) + state = .recovering(recoveringState) + let newInvoice: POInvoice + if shouldCreateNewInvoice(toRecoverFrom: failure, in: currentState) { + if let invoice = await delegate?.dynamicCheckout(newInvoiceFor: currentState.snapshot.invoice) { + newInvoice = invoice } else { - newStartedState.pendingUnavailablePaymentMethodIds.subtract(pendingUnavailableIds) - newStartedState.unavailablePaymentMethodIds.formUnion(pendingUnavailableIds) + setFailureStateUnchecked(error: failure) + return } - case .card where !canRecoverCardTokenization(from: failure): - let unavailableIds = paymentMethodIds(of: .card, state: currentState.snapshot) - newStartedState.unavailablePaymentMethodIds.formUnion(unavailableIds) + } else { + newInvoice = currentState.snapshot.invoice + } + finishPaymentFailureRecovery(with: newInvoice) + } + + private func finishPaymentFailureRecovery(with newInvoice: POInvoice) { + guard case .recovering(let currentState) = state else { + assertionFailure("Unexpected state") + return + } + setStartedStateUnchecked( + invoice: newInvoice, errorDescription: failureDescription(currentState.failure) + ) + guard let pendingPaymentMethodId = currentState.pendingPaymentMethodId else { + return + } + if currentState.shouldStartPendingPaymentMethod { + startPayment(methodId: pendingPaymentMethodId) + } else { + select(methodId: pendingPaymentMethodId) + } + // todo(andrii-vysotskyi): decide whether input should be preserved for card tokenization + } + + private func shouldCreateNewInvoice( + toRecoverFrom failure: POFailure, in state: State.PaymentProcessing + ) -> Bool { + if state.shouldInvalidateInvoice { + return true + } + // todo(andrii-vysotskyi): decide whether errors list is correct + switch failure.code { + case .internal, .validation, .notFound, .generic, .unknown: + return true default: - break + return false + } + } + + private func failureDescription(_ failure: POFailure) -> String? { + if case .cancelled = failure.code { + return nil } - newStartedState.recentErrorDescription = String(resource: .DynamicCheckout.Error.generic) - self.state = .started(newStartedState) + return String(resource: .DynamicCheckout.Error.generic) } // MARK: - Failure State @@ -634,6 +667,13 @@ final class DynamicCheckoutDefaultInteractor: } return paymentMethod } + + private func invalidateInvoiceIfPossible() { + if case .paymentProcessing(var currentState) = state { + currentState.shouldInvalidateInvoice = true + state = .paymentProcessing(currentState) + } + } } @available(iOS 14.0, *) @@ -643,11 +683,17 @@ extension DynamicCheckoutDefaultInteractor: POCardTokenizationDelegate { delegate?.dynamicCheckout(didEmitCardTokenizationEvent: event) } + @MainActor func processTokenizedCard(card: POCard) async throws { + invalidateInvoiceIfPossible() + guard case .paymentProcessing(let currentState) = state else { + assertionFailure("Unable to process card in unsupported state.") + throw POFailure(code: .internal(.mobile)) + } guard let delegate else { throw POFailure(message: "Delegate must be set to authorize invoice.", code: .generic(.mobile)) } - var request = POInvoiceAuthorizationRequest(invoiceId: configuration.invoiceId, source: card.id) + var request = POInvoiceAuthorizationRequest(invoiceId: currentState.snapshot.invoice.id, source: card.id) let threeDSService = await delegate.dynamicCheckout(willAuthorizeInvoiceWith: &request) try await invoicesService.authorizeInvoice(request: request, threeDSService: threeDSService) } @@ -667,7 +713,7 @@ extension DynamicCheckoutDefaultInteractor: PONativeAlternativePaymentDelegate { func nativeAlternativePaymentMethodDidEmitEvent(_ event: PONativeAlternativePaymentMethodEvent) { switch event { case .didSubmitParameters: - updateUnavailablePaymentMethods() + invalidateInvoiceIfPossible() default: break } @@ -682,18 +728,6 @@ extension DynamicCheckoutDefaultInteractor: PONativeAlternativePaymentDelegate { completion(values) } } - - // MARK: - Private Methods - - private func updateUnavailablePaymentMethods() { - guard case .paymentProcessing(var currentState) = state else { - return - } - var unavailableIds = paymentMethodIds(of: .nativeAlternativePayment, state: currentState.snapshot) - unavailableIds.remove(currentState.paymentMethodId) - currentState.snapshot.pendingUnavailablePaymentMethodIds.formUnion(unavailableIds) - state = .paymentProcessing(currentState) - } } // swiftlint:enable file_length type_body_length diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutInteractorState.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutInteractorState.swift index 2b7a1e122..f9a60432c 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutInteractorState.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckout/DynamicCheckoutInteractorState.swift @@ -27,11 +27,8 @@ enum DynamicCheckoutInteractorState { /// Defines whether payment is cancellable. let isCancellable: Bool - /// During module lifecycle certain payment methods may become unavailable. - var unavailablePaymentMethodIds: Set = [] - - /// Payment methods that will be set unavailable when this payment method ends. - var pendingUnavailablePaymentMethodIds: Set = [] + /// Current invoice. + var invoice: POInvoice /// Most recent error description if any. var recentErrorDescription: String? @@ -49,7 +46,7 @@ enum DynamicCheckoutInteractorState { struct PaymentProcessing { /// Started state snapshot. - var snapshot: Started + let snapshot: Started /// Payment method ID that is currently being processed. let paymentMethodId: String @@ -85,6 +82,10 @@ enum DynamicCheckoutInteractorState { /// When processing fails and this property is set to `true`, pending payment method (if present) is /// started after selection. var shouldStartPendingPaymentMethod = false + + /// Boolean value indicating whether invoice should be invalidated when interactor transitions back + /// to started from this state. + var shouldInvalidateInvoice = false } enum PaymentSubmission { @@ -99,6 +100,25 @@ enum DynamicCheckoutInteractorState { case submitting } + struct Recovering { + + /// Failure that caused recovery process to happen. + let failure: POFailure + + /// Started state snapshot. + let snapshot: Started + + /// Failed payment method ID. + let failedPaymentMethodId: String + + /// Payment method that should be selected in case of processing failure. + var pendingPaymentMethodId: String? + + /// When processing fails and this property is set to `true`, pending payment method (if present) is + /// started after selection. + var shouldStartPendingPaymentMethod = false + } + /// Idle state. case idle @@ -114,6 +134,9 @@ enum DynamicCheckoutInteractorState { /// Payment is being processed. case paymentProcessing(PaymentProcessing) + /// Payment recovering state. + case recovering(Recovering) + /// Failure state. This is a sink state. case failure(POFailure) diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentDefaultSession.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentDefaultSession.swift index 55c7532f2..3907e7d18 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentDefaultSession.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentDefaultSession.swift @@ -12,12 +12,7 @@ import ProcessOut @MainActor final class DynamicCheckoutPassKitPaymentDefaultSession: DynamicCheckoutPassKitPaymentSession { - init( - configuration: PODynamicCheckoutConfiguration, - delegate: PODynamicCheckoutDelegate?, - invoicesService: POInvoicesService - ) { - self.configuration = configuration + init(delegate: PODynamicCheckoutDelegate?, invoicesService: POInvoicesService) { self.delegate = delegate self.invoicesService = invoicesService didAuthorizeInvoice = false @@ -27,12 +22,13 @@ final class DynamicCheckoutPassKitPaymentDefaultSession: DynamicCheckoutPassKitP POPassKitPaymentAuthorizationController.canMakePayments() } - func start(request: PKPaymentRequest) async throws { + func start(invoiceId: String, request: PKPaymentRequest) async throws { await delegate?.dynamicCheckout(willAuthorizeInvoiceWith: request) guard let controller = POPassKitPaymentAuthorizationController(paymentRequest: request) else { assertionFailure("ApplePay payment shouldn't be attempted when unavailable.") throw POFailure(code: .generic(.mobile)) } + self.invoiceId = invoiceId controller.delegate = self _ = await controller.present() await withCheckedContinuation { continuation in @@ -48,11 +44,12 @@ final class DynamicCheckoutPassKitPaymentDefaultSession: DynamicCheckoutPassKitP // MARK: - Private Properties private let invoicesService: POInvoicesService - private let configuration: PODynamicCheckoutConfiguration - private weak var delegate: PODynamicCheckoutDelegate? private var didFinishContinuation: CheckedContinuation? private var didAuthorizeInvoice: Bool + private var invoiceId: String? + + private weak var delegate: PODynamicCheckoutDelegate? } extension DynamicCheckoutPassKitPaymentDefaultSession: POPassKitPaymentAuthorizationControllerDelegate { @@ -69,7 +66,10 @@ extension DynamicCheckoutPassKitPaymentDefaultSession: POPassKitPaymentAuthoriza didTokenizePayment payment: PKPayment, card: POCard ) async -> PKPaymentAuthorizationResult { - var authorizationRequest = POInvoiceAuthorizationRequest(invoiceId: configuration.invoiceId, source: card.id) + guard let invoiceId else { + preconditionFailure("Invoice ID must be set.") + } + var authorizationRequest = POInvoiceAuthorizationRequest(invoiceId: invoiceId, source: card.id) do { guard let delegate else { throw POFailure(message: "Delegate must be set to authorize invoice.", code: .generic(.mobile)) diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentSession.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentSession.swift index dd13fb37d..25a70418d 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentSession.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Sessions/PassKit/DynamicCheckoutPassKitPaymentSession.swift @@ -13,5 +13,5 @@ protocol DynamicCheckoutPassKitPaymentSession { var isSupported: Bool { get } /// Starts payment. - func start(request: PKPaymentRequest) async throws + func start(invoiceId: String, request: PKPaymentRequest) async throws } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle+Default.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle+Default.swift index d5682ce52..5b53659d6 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle+Default.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle+Default.swift @@ -37,8 +37,7 @@ extension PODynamicCheckoutStyle.RegularPaymentMethod { public static let `default` = Self( title: POTextStyle(color: Color(poResource: .Text.primary), typography: .subheading), informationText: POTextStyle(color: Color(poResource: .Text.muted), typography: .body2), - border: POBorderStyle.regular(color: Color(poResource: .Border.subtle)), - disabledBackgroundColor: Color(poResource: .Surface.neutral) + border: POBorderStyle.regular(color: Color(poResource: .Border.subtle)) ) } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle.swift index f3770b595..8bf8f6ca1 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Style/PODynamicCheckoutStyle.swift @@ -28,19 +28,10 @@ public struct PODynamicCheckoutStyle { /// Border style to apply to regular payments. public let border: POBorderStyle - /// Background color to use when payment method is unavailable. - public let disabledBackgroundColor: Color - - public init( - title: POTextStyle, - informationText: POTextStyle, - border: POBorderStyle, - disabledBackgroundColor: Color - ) { + public init(title: POTextStyle, informationText: POTextStyle, border: POBorderStyle) { self.title = title self.informationText = informationText self.border = border - self.disabledBackgroundColor = disabledBackgroundColor } } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Symbols/StringResource+DynamicCheckout.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Symbols/StringResource+DynamicCheckout.swift index a049b1204..040ff21c1 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Symbols/StringResource+DynamicCheckout.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Symbols/StringResource+DynamicCheckout.swift @@ -36,9 +36,6 @@ extension POStringResource { enum Error { - /// Indicates that selected payment method is not available. - static let unavailableMethod = POStringResource("dynamic-checkout.error.unavailable-method", comment: "") - /// Indicates that implementation is unable to process payment. static let generic = POStringResource("dynamic-checkout.error.generic", comment: "") } @@ -47,9 +44,6 @@ extension POStringResource { /// APM redirect information. static let redirect = POStringResource("dynamic-checkout.redirect-warning", comment: "") - - /// Warns user that payment method is unavailable. - static let paymentUnavailable = POStringResource("dynamic-checkout.unavailable-warning", comment: "") } /// Success message. diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentInfoView.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentInfoView.swift index 92c7c5ca6..774848bfd 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentInfoView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentInfoView.swift @@ -50,7 +50,6 @@ struct DynamicCheckoutRegularPaymentInfoView: View { .onTapGesture { item.isSelected = true } - .disabled(!item.isSelectable) .backport.geometryGroup() } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentView.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentView.swift index 66ea41842..b82491595 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutRegularPaymentView.swift @@ -18,8 +18,10 @@ struct DynamicCheckoutRegularPaymentView: View { DynamicCheckoutRegularPaymentInfoView(item: item.info) if case .card(let item) = item.content { DynamicCheckoutCardView(item: item) + .id(self.item.contentId) } else if case .alternativePayment(let item) = item.content { DynamicCheckoutAlternativePaymentView(item: item) + .id(self.item.contentId) } if let item = item.submitButton { Button(item.title, action: item.action) diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutSectionView.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutSectionView.swift index 3d956133f..738f782cd 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutSectionView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutSectionView.swift @@ -27,7 +27,9 @@ struct DynamicCheckoutSectionView: View { .padding(section.areBezelsVisible ? POSpacing.large : 0) .fixedSize(horizontal: false, vertical: true) } - .background(background(for: item)) + .background( + style.backgroundColor.opacity(section.areBezelsVisible ? 1 : 0) + ) .backport.geometryGroup() } } @@ -39,17 +41,4 @@ struct DynamicCheckoutSectionView: View { @Environment(\.dynamicCheckoutStyle) private var style - - // MARK: - Private Methods - - @ViewBuilder - private func background(for item: DynamicCheckoutViewModelState.Item) -> some View { - if !section.areBezelsVisible { - style.backgroundColor.opacity(0) - } else if case .regularPayment(let item) = item, !item.info.isSelectable { - style.regularPaymentMethod.disabledBackgroundColor - } else { - style.backgroundColor - } - } } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/PODynamicCheckoutView+Init.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/PODynamicCheckoutView+Init.swift index f67a8083a..8523f90f8 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/PODynamicCheckoutView+Init.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/PODynamicCheckoutView+Init.swift @@ -21,13 +21,12 @@ extension PODynamicCheckoutView { completion: @escaping (Result) -> Void ) { let viewModel = { - var logger = ProcessOut.shared.logger - logger[attributeKey: .invoiceId] = configuration.invoiceId + let logger = ProcessOut.shared.logger let interactor = DynamicCheckoutDefaultInteractor( configuration: configuration, delegate: delegate, passKitPaymentSession: DynamicCheckoutPassKitPaymentDefaultSession( - configuration: configuration, delegate: delegate, invoicesService: ProcessOut.shared.invoices + delegate: delegate, invoicesService: ProcessOut.shared.invoices ), alternativePaymentSession: DynamicCheckoutAlternativePaymentDefaultSession( configuration: configuration.alternativePayment diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift index 4e3485fd3..35b36a280 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift @@ -71,6 +71,8 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { updateWithSelectedState(state) case .paymentProcessing(let state): updateWithPaymentProcessingState(state) + case .recovering(let state): + updateWithRecoveringState(state) case .success: updateWithSuccessState() } @@ -112,11 +114,9 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { guard let info = createPaymentInfo(id: methodId, isSelected: isSelected, isLoading: false, state: state) else { return nil } + let submitButton = createSubmitAction(methodId: methodId, selectedMethodId: selectedMethodId) let payment = DynamicCheckoutViewModelItem.RegularPayment( - id: methodId, - info: info, - content: nil, - submitButton: createSubmitAction(methodId: methodId, selectedMethodId: selectedMethodId) + id: methodId, info: info, content: nil, contentId: "", submitButton: submitButton ) return .regularPayment(payment) } @@ -219,15 +219,13 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { self?.didSelectPaymentItem(id: id, isExternal: isExternal) } } - let isAvailable = !state.unavailablePaymentMethodIds.contains(id) let item = DynamicCheckoutViewModelItem.RegularPaymentInfo( iconImageResource: display.logo, title: display.name, isLoading: isLoading, - isSelectable: isAvailable, isSelected: isSelected, additionalInformation: additionalPaymentInformation( - methodId: id, isAvailable: isAvailable, isExternal: isExternal, isSelected: selected + methodId: id, isExternal: isExternal, isSelected: selected ) ) return item @@ -241,12 +239,8 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { } } - private func additionalPaymentInformation( - methodId: String, isAvailable: Bool, isExternal: Bool, isSelected: Bool - ) -> String? { - if !isAvailable { - return String(resource: .DynamicCheckout.Warning.paymentUnavailable) - } else if isExternal, isSelected { + private func additionalPaymentInformation(methodId: String, isExternal: Bool, isSelected: Bool) -> String? { + if isExternal, isSelected { return String(resource: .DynamicCheckout.Warning.redirect) } return nil @@ -320,15 +314,16 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { ] // swiftlint:disable:next line_length let regularItems = state.snapshot.regularPaymentMethodIds.compactMap { methodId -> DynamicCheckoutViewModelItem? in - let isSelected = state.paymentMethodId == methodId + let (isSelected, isLoading) = status(of: methodId, state: state) // swiftlint:disable:next line_length - guard let info = createPaymentInfo(id: methodId, isSelected: isSelected, isLoading: isSelected && !state.isReady, state: state.snapshot) else { + guard let info = createPaymentInfo(id: methodId, isSelected: isSelected, isLoading: isLoading, state: state.snapshot) else { return nil } let payment = DynamicCheckoutViewModelItem.RegularPayment( id: methodId, info: info, content: createRegularPaymentContent(state: state, methodId: methodId), + contentId: state.snapshot.invoice.id, submitButton: createSubmitAction(methodId: methodId, state: state) ) return .regularPayment(payment) @@ -343,7 +338,7 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { private func createRegularPaymentContent( state: DynamicCheckoutInteractorState.PaymentProcessing, methodId: String ) -> DynamicCheckoutViewModelItem.RegularPaymentContent? { - guard state.isReady, state.paymentMethodId == methodId else { + guard shouldResolveContent(for: methodId, state: state) else { return nil } guard let method = state.snapshot.paymentMethods[methodId] else { @@ -380,7 +375,7 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { private func createSubmitAction( methodId: String, state: DynamicCheckoutInteractorState.PaymentProcessing ) -> POActionsContainerActionViewModel? { - guard methodId == state.paymentMethodId, state.isReady else { + guard shouldResolveContent(for: methodId, state: state) else { return nil } let isEnabled, isLoading: Bool @@ -428,6 +423,75 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { return createCancelAction(title: title, isEnabled: state.isCancellable, confirmation: confirmation) } + private func shouldResolveContent( + for methodId: String, state: DynamicCheckoutInteractorState.PaymentProcessing + ) -> Bool { + state.paymentMethodId == methodId && state.isReady && state.pendingPaymentMethodId == nil + } + + private func status( + of methodId: String, state: DynamicCheckoutInteractorState.PaymentProcessing + ) -> (isSelected: Bool, isLoading: Bool) { + if let pendingPaymentMethodId = state.pendingPaymentMethodId { + if methodId == pendingPaymentMethodId { + return (true, true) + } + } else if methodId == state.paymentMethodId { + return (true, !state.isReady) + } + return (false, false) + } + + // MARK: - Recovering State + + private func updateWithRecoveringState(_ state: DynamicCheckoutInteractorState.Recovering) { + let newActions = [ + createCancelAction(state) + ] + let newState = DynamicCheckoutViewModelState( + sections: createSectionsWithRecoveringState(state), + actions: newActions.compactMap { $0 }, + isCompleted: false + ) + self.state = newState + } + + private func createSectionsWithRecoveringState( + _ state: DynamicCheckoutInteractorState.Recovering + ) -> [DynamicCheckoutViewModelState.Section] { + var sections = [ + createErrorSection(state: state.snapshot), + createExpressMethodsSection(state: state.snapshot) + ] + // swiftlint:disable:next line_length + let regularItems = state.snapshot.regularPaymentMethodIds.compactMap { methodId -> DynamicCheckoutViewModelItem? in + let isSelected = methodId == (state.pendingPaymentMethodId ?? state.failedPaymentMethodId) + // swiftlint:disable:next line_length + guard let info = createPaymentInfo(id: methodId, isSelected: isSelected, isLoading: isSelected, state: state.snapshot) else { + return nil + } + let payment = DynamicCheckoutViewModelItem.RegularPayment( + id: methodId, info: info, content: nil, contentId: "", submitButton: nil + ) + return .regularPayment(payment) + } + let regularSection = DynamicCheckoutViewModelState.Section( + id: SectionId.regularMethods, items: regularItems, isTight: true, areBezelsVisible: true + ) + sections.append(regularSection) + return sections.compactMap { $0 } + } + + private func createCancelAction( + _ state: DynamicCheckoutInteractorState.Recovering + ) -> POActionsContainerActionViewModel? { + guard state.snapshot.isCancellable else { + return nil + } + let title = interactor.configuration.cancelButton?.title + return createCancelAction(title: title, isEnabled: false, confirmation: nil) + } + // MARK: - Success private func updateWithSuccessState() { diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelItem.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelItem.swift index 2581dcc9d..10480cdf6 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelItem.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DynamicCheckoutViewModelItem.swift @@ -53,6 +53,9 @@ enum DynamicCheckoutViewModelItem { /// Payment content. let content: RegularPaymentContent? + /// Content ID. + let contentId: String + /// Submits payment information. let submitButton: POActionsContainerActionViewModel? } @@ -68,9 +71,6 @@ enum DynamicCheckoutViewModelItem { /// Indicates whether loading indicator should be visible. let isLoading: Bool - /// Defines whether selection could be changed. - let isSelectable: Bool - /// Defines whether item is currently selected. @Binding var isSelected: Bool