diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Configuration/PODynamicCheckoutAlternativePaymentConfiguration.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Configuration/PODynamicCheckoutAlternativePaymentConfiguration.swift index 27ff69ad5..f6adacd14 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Configuration/PODynamicCheckoutAlternativePaymentConfiguration.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Configuration/PODynamicCheckoutAlternativePaymentConfiguration.swift @@ -13,6 +13,18 @@ public struct PODynamicCheckoutAlternativePaymentConfiguration { public struct PaymentConfirmation { + /// Confirmation button configuration. + public struct ConfirmButton { // swiftlint:disable:this nesting + + /// Button title. + public let title: String? + + /// Creates button instance. + public init(title: String? = nil) { + self.title = title + } + } + /// Amount of time (in seconds) that module is allowed to wait before receiving final payment confirmation. /// Default timeout is 3 minutes while maximum value is 15 minutes. public let timeout: TimeInterval @@ -20,6 +32,13 @@ public struct PODynamicCheckoutAlternativePaymentConfiguration { /// A delay before showing progress indicator during payment confirmation. public let showProgressIndicatorAfter: TimeInterval? + /// Payment confirmation button configuration. + /// + /// Displays a confirmation button when the user needs to perform an external customer action (e.g., + /// completing a step with a third-party service) before proceeding with payment capture. The user + /// must press this button to continue. + public let confirmButton: ConfirmButton? + /// Action that could be optionally presented to user during payment confirmation stage. To remove action /// use `nil`, this is default behaviour. public let cancelButton: CancelButton? @@ -28,10 +47,12 @@ public struct PODynamicCheckoutAlternativePaymentConfiguration { public init( timeout: TimeInterval = 180, showProgressIndicatorAfter: TimeInterval? = nil, + confirmButton: ConfirmButton? = nil, cancelButton: CancelButton? = nil ) { self.timeout = timeout self.showProgressIndicatorAfter = showProgressIndicatorAfter + self.confirmButton = confirmButton self.cancelButton = cancelButton } } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift index c92ff32da..4905a5c03 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutDefaultInteractor.swift @@ -103,20 +103,6 @@ final class DynamicCheckoutDefaultInteractor: } } - func submit() { - guard case .paymentProcessing(let currentState) = state else { - return - } - switch currentPaymentMethod(state: currentState) { - case .card: - currentState.cardTokenizationInteractor?.tokenize() - case .nativeAlternativePayment: - currentState.nativeAlternativePaymentInteractor?.submit() - default: - assertionFailure("Active payment method doesn't support forced submission") - } - } - override func cancel() { cancel(force: true) } @@ -368,9 +354,8 @@ final class DynamicCheckoutDefaultInteractor: paymentMethodId: methodId, cardTokenizationInteractor: nil, nativeAlternativePaymentInteractor: nil, - submission: .submitting, isCancellable: false, - shouldInvalidateInvoice: true + shouldInvalidateInvoice: false ) state = .paymentProcessing(paymentProcessingState) Task { @MainActor in @@ -378,17 +363,18 @@ final class DynamicCheckoutDefaultInteractor: guard let delegate else { throw POFailure(message: "Delegate must be set to authorize invoice.", code: .generic(.mobile)) } - let tokenizationRequest = POApplePayTokenizationRequest(paymentRequest: request) - let coordinator = DynamicCheckoutApplePayTokenizationCoordinator { [invoicesService] card in - var authorizationRequest = POInvoiceAuthorizationRequest( - invoiceId: startedState.invoice.id, source: card.id - ) - let threeDSService = await delegate.dynamicCheckout(willAuthorizeInvoiceWith: &authorizationRequest) - try await invoicesService.authorizeInvoice( - request: authorizationRequest, threeDSService: threeDSService - ) - } - _ = try await cardsService.tokenize(request: tokenizationRequest, delegate: coordinator) + await delegate.dynamicCheckout(willAuthorizeInvoiceWith: request) + let card = try await cardsService.tokenize( + request: POApplePayTokenizationRequest(paymentRequest: request) + ) + invalidateInvoiceIfPossible() + var authorizationRequest = POInvoiceAuthorizationRequest( + invoiceId: startedState.invoice.id, source: card.id + ) + let threeDSService = await delegate.dynamicCheckout(willAuthorizeInvoiceWith: &authorizationRequest) + try await invoicesService.authorizeInvoice( + request: authorizationRequest, threeDSService: threeDSService + ) setSuccessState() } catch { recoverPaymentProcessing(error: error) @@ -411,7 +397,6 @@ final class DynamicCheckoutDefaultInteractor: paymentMethodId: method.id, cardTokenizationInteractor: interactor, nativeAlternativePaymentInteractor: nil, - submission: .possible, isCancellable: true ) state = .paymentProcessing(paymentProcessingState) @@ -428,11 +413,9 @@ final class DynamicCheckoutDefaultInteractor: case .idle: break // Ignored case .started(let startedState): - currentState.submission = startedState.areParametersValid ? .possible : .temporarilyUnavailable currentState.isCancellable = currentState.snapshot.isCancellable self.state = .paymentProcessing(currentState) case .tokenizing: - currentState.submission = .submitting currentState.isCancellable = false self.state = .paymentProcessing(currentState) case .tokenized: @@ -452,7 +435,6 @@ final class DynamicCheckoutDefaultInteractor: paymentMethodId: method.id, cardTokenizationInteractor: nil, nativeAlternativePaymentInteractor: nil, - submission: .submitting, isCancellable: false, shouldInvalidateInvoice: true ) @@ -492,7 +474,6 @@ final class DynamicCheckoutDefaultInteractor: paymentMethodId: method.id, cardTokenizationInteractor: nil, nativeAlternativePaymentInteractor: interactor, - submission: .submitting, isCancellable: false, isReady: false ) @@ -510,25 +491,21 @@ final class DynamicCheckoutDefaultInteractor: case .idle: break // Ignored case .starting: - currentState.submission = .submitting currentState.isCancellable = false currentState.isReady = false currentState.isAwaitingNativeAlternativePaymentCapture = false self.state = .paymentProcessing(currentState) case .started(let startedState): - currentState.submission = startedState.areParametersValid ? .possible : .temporarilyUnavailable currentState.isCancellable = startedState.isCancellable currentState.isReady = true currentState.isAwaitingNativeAlternativePaymentCapture = false self.state = .paymentProcessing(currentState) case .submitting(let submittingState): - currentState.submission = .submitting currentState.isCancellable = submittingState.isCancellable currentState.isReady = true currentState.isAwaitingNativeAlternativePaymentCapture = false self.state = .paymentProcessing(currentState) case .awaitingCapture(let awaitingCaptureState): - currentState.submission = .submitting currentState.isCancellable = awaitingCaptureState.isCancellable currentState.isReady = true currentState.isAwaitingNativeAlternativePaymentCapture = true @@ -550,7 +527,6 @@ final class DynamicCheckoutDefaultInteractor: paymentMethodId: method.id, cardTokenizationInteractor: nil, nativeAlternativePaymentInteractor: nil, - submission: .submitting, isCancellable: false, shouldInvalidateInvoice: true ) diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractor.swift index c55311625..0c65a46fe 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractor.swift @@ -17,9 +17,6 @@ protocol DynamicCheckoutInteractor: Interactor { /// Please note that only selected payment method can be started. func startPayment(methodId: String) - /// Submits current payment method's data. - func submit() - /// Notifies interactor that user requested cancel confirmation. func didRequestCancelConfirmation() } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractorState.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractorState.swift index d66375a67..25c0e06e7 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractorState.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Interactor/DynamicCheckoutInteractorState.swift @@ -60,9 +60,6 @@ enum DynamicCheckoutInteractorState { /// Native APM interactor. let nativeAlternativePaymentInteractor: (any NativeAlternativePaymentInteractor)? - /// Submission state. - var submission: PaymentSubmission - /// Defines whether payment is cancellable. var isCancellable: Bool @@ -91,18 +88,6 @@ enum DynamicCheckoutInteractorState { var shouldInvalidateInvoice = false } - enum PaymentSubmission { - - /// Submission is currently unavailable. - case temporarilyUnavailable - - /// Submission is currently possible. - case possible - - /// Payment is already being processed. - case submitting - } - struct Recovering { /// Failure that caused recovery process to happen. diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ApplePay/DynamicCheckoutApplePayTokenizationCoordinator.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ApplePay/DynamicCheckoutApplePayTokenizationCoordinator.swift deleted file mode 100644 index d5e4ae204..000000000 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ApplePay/DynamicCheckoutApplePayTokenizationCoordinator.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// DynamicCheckoutApplePayTokenizationCoordinator.swift -// ProcessOutUI -// -// Created by Andrii Vysotskyi on 17.03.2024. -// - -import Foundation -import PassKit -import ProcessOut - -final class DynamicCheckoutApplePayTokenizationCoordinator: POApplePayTokenizationDelegate { - - init(didTokenizeCard: @escaping (POCard) async throws -> Void) { - self.didTokenizeCard = didTokenizeCard - } - - /// Closure that is called when invoice is authorized. - let didTokenizeCard: (POCard) async throws -> Void - - // MARK: - - - func applePayTokenization( - didAuthorizePayment payment: PKPayment, card: POCard - ) async -> PKPaymentAuthorizationResult { - do { - try await didTokenizeCard(card) - } catch { - return .init(status: .failure, errors: [error]) - } - return .init(status: .success, errors: nil) - } -} diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift index 46c5d7846..8f8c1bd67 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/Utils/ChildProvider/DynamicCheckoutInteractorDefaultChildProvider.swift @@ -81,7 +81,7 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera title: "", isCardholderNameInputVisible: configuration.cardholderNameRequired, shouldCollectCvc: configuration.cvcRequired, - primaryActionTitle: "", + primaryActionTitle: self.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay), cancelActionTitle: "", billingAddress: billingAddressConfiguration, isSavingAllowed: configuration.savingAllowed, @@ -99,7 +99,7 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera title: "", shouldHorizontallyCenterCodeInput: false, successMessage: "", - primaryActionTitle: "", + primaryActionTitle: self.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay), secondaryAction: nil, inlineSingleSelectValuesLimit: configuration.alternativePayment.inlineSingleSelectValuesLimit, skipSuccessScreen: true, @@ -116,6 +116,9 @@ final class DynamicCheckoutInteractorDefaultChildProvider: DynamicCheckoutIntera timeout: configuration.timeout, showProgressIndicatorAfter: configuration.showProgressIndicatorAfter, hideGatewayDetails: true, + confirmButton: configuration.confirmButton.map { button in + .init(title: button.title) + }, secondaryAction: configuration.cancelButton.map { configuration in .cancel(title: "", disabledFor: configuration.disabledFor, confirmation: nil) } diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutAlternativePaymentView.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutAlternativePaymentView.swift index 16a6ee415..db5553001 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutAlternativePaymentView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutAlternativePaymentView.swift @@ -18,9 +18,17 @@ struct DynamicCheckoutAlternativePaymentView: View { // MARK: - View var body: some View { - NativeAlternativePaymentContentView(viewModel: viewModel, insets: 0) - .nativeAlternativePaymentSizeClass(.compact) - .nativeAlternativePaymentStyle(.init(dynamicCheckoutStyle: style)) + VStack(spacing: POSpacing.large) { + NativeAlternativePaymentContentView(viewModel: viewModel, insets: 0) + .nativeAlternativePaymentSizeClass(.compact) + .nativeAlternativePaymentStyle(.init(dynamicCheckoutStyle: style)) + ForEach(viewModel.state.actions.filter(\.isPrimary)) { button in + Button(button.title, action: button.action) + .buttonStyle(POAnyButtonStyle(erasing: style.actionsContainer.primary)) + .disabled(!button.isEnabled) + .buttonLoading(button.isLoading) + } + } } // MARK: - Private Properties diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutCardView.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutCardView.swift index f761c8ce1..69d47770a 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutCardView.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/View/DynamicCheckoutCardView.swift @@ -18,8 +18,16 @@ struct DynamicCheckoutCardView: View { // MARK: - View var body: some View { - CardTokenizationContentView(viewModel: viewModel, insets: 0) - .cardTokenizationStyle(.init(dynamicCheckoutStyle: style)) + VStack(spacing: POSpacing.large) { + CardTokenizationContentView(viewModel: viewModel, insets: 0) + .cardTokenizationStyle(.init(dynamicCheckoutStyle: style)) + ForEach(viewModel.state.actions.filter(\.isPrimary)) { button in + Button(button.title, action: button.action) + .buttonStyle(POAnyButtonStyle(erasing: style.actionsContainer.primary)) + .disabled(!button.isEnabled) + .buttonLoading(button.isLoading) + } + } } // MARK: - Private Properties diff --git a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift index 8e4e76121..69b0a02d7 100644 --- a/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift +++ b/Sources/ProcessOutUI/Sources/Modules/DynamicCheckout/ViewModel/DefaultDynamicCheckoutViewModel.swift @@ -329,7 +329,7 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { info: info, content: createRegularPaymentContent(state: state, methodId: methodId), contentId: state.snapshot.invoice.id, - submitButton: createSubmitAction(methodId: methodId, state: state) + submitButton: nil ) return .regularPayment(payment) } @@ -377,37 +377,6 @@ final class DefaultDynamicCheckoutViewModel: ViewModel { } } - private func createSubmitAction( - methodId: String, state: DynamicCheckoutInteractorState.PaymentProcessing - ) -> POActionsContainerActionViewModel? { - guard shouldResolveContent(for: methodId, state: state) else { - return nil - } - let isEnabled, isLoading: Bool - switch state.submission { - case .temporarilyUnavailable: - isEnabled = false - isLoading = false - case .possible: - isEnabled = true - isLoading = false - case .submitting: - isEnabled = true - isLoading = true - } - let viewModel = POActionsContainerActionViewModel( - id: ButtonId.submit, - title: interactor.configuration.submitButtonTitle ?? String(resource: .DynamicCheckout.Button.pay), - isEnabled: isEnabled, - isLoading: isLoading, - isPrimary: true, - action: { [weak self] in - self?.interactor.submit() - } - ) - return viewModel - } - private func createCancelAction( _ state: DynamicCheckoutInteractorState.PaymentProcessing ) -> POActionsContainerActionViewModel? {