diff --git a/.github/actions/select-xcode/action.yml b/.github/actions/select-xcode/action.yml index 96ede897f..d232ef4bb 100644 --- a/.github/actions/select-xcode/action.yml +++ b/.github/actions/select-xcode/action.yml @@ -4,5 +4,5 @@ runs: using: "composite" steps: - name: Select Xcode Version - run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer' + run: sudo xcode-select -s '/Applications/Xcode_16.app/Contents/Developer' shell: bash diff --git a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift index a5345d6ea..bfffbb9fc 100644 --- a/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift +++ b/Example/Example/Sources/UI/Modules/AlternativePayments/ViewModel/AlternativePaymentsViewModel.swift @@ -168,6 +168,7 @@ final class AlternativePaymentsViewModel: ObservableObject { secondaryAction: .cancel(), paymentConfirmation: .init( showProgressIndicatorAfter: 5, + confirmButton: .init(), secondaryAction: .cancel(disabledFor: 10) ) ) diff --git a/Scripts/Test.sh b/Scripts/Test.sh index dc5d50b12..a6dc7a386 100755 --- a/Scripts/Test.sh +++ b/Scripts/Test.sh @@ -5,8 +5,11 @@ set -euo pipefail PROJECT='ProcessOut.xcodeproj' DESTINATION=$(./Scripts/TestDestination.swift) +# todo(andrii-vysotskyi): re-enable Checkout3DS wrapper testing +# ProcessOutCheckout3DS is not compatible with Xcode 16 + # Run Tests -for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutCheckout3DS"; do +for PRODUCT in "ProcessOut" "ProcessOutUI"; do xcodebuild clean test \ -destination "$DESTINATION" \ -project $PROJECT \ diff --git a/Sources/ProcessOut/Sources/UI/Modules/NativeAlternativePaymentMethod/Models/PONativeAlternativePaymentMethodEvent.swift b/Sources/ProcessOut/Sources/UI/Modules/NativeAlternativePaymentMethod/Models/PONativeAlternativePaymentMethodEvent.swift index e2ec3a1cf..bb9d53927 100644 --- a/Sources/ProcessOut/Sources/UI/Modules/NativeAlternativePaymentMethod/Models/PONativeAlternativePaymentMethodEvent.swift +++ b/Sources/ProcessOut/Sources/UI/Modules/NativeAlternativePaymentMethod/Models/PONativeAlternativePaymentMethodEvent.swift @@ -75,6 +75,11 @@ public enum PONativeAlternativePaymentMethodEvent { /// to make capture happen. case willWaitForCaptureConfirmation(additionalActionExpected: Bool) + /// This event is triggered during the capture stage when the user confirms that they have completed + /// any required external action (if applicable). Once the event is triggered, the implementation + /// proceeds with the actual capture process. + case didConfirmPayment + /// Event is sent after payment was confirmed to be captured. This is a final event. case didCompletePayment diff --git a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Regular/POButtonStyle.swift b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Regular/POButtonStyle.swift index f5fb0483a..f62ba6f1e 100644 --- a/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Regular/POButtonStyle.swift +++ b/Sources/ProcessOutCoreUI/Sources/DesignSystem/Button/Regular/POButtonStyle.swift @@ -43,20 +43,21 @@ public struct POButtonStyle: ButtonStyle { isEnabled: isEnabled, isLoading: isLoading, isPressed: configuration.isPressed ) ZStack { - ProgressView() - .progressViewStyle(progressStyle) - .opacity(isLoading ? 1 : 0) - configuration.label - .textStyle(currentStyle.title) - .lineLimit(1) - .opacity(isLoading ? 0 : 1) + if isLoading { + ProgressView() + .progressViewStyle(progressStyle) + } else { + configuration.label + .textStyle(currentStyle.title) + .lineLimit(1) + } } .padding(Constants.padding) .frame(maxWidth: .infinity, minHeight: Constants.minHeight) .background(currentStyle.backgroundColor) .border(style: currentStyle.border) .shadow(style: currentStyle.shadow) - .contentShape(.rect) + .contentShape(.standardHittableRect) .animation(.default, value: isLoading) .animation(.default, value: isEnabled) .allowsHitTesting(isEnabled && !isLoading) diff --git a/Sources/ProcessOutUI/Resources/Localizable.xcstrings b/Sources/ProcessOutUI/Resources/Localizable.xcstrings index 476012b47..ec1309af7 100644 --- a/Sources/ProcessOutUI/Resources/Localizable.xcstrings +++ b/Sources/ProcessOutUI/Resources/Localizable.xcstrings @@ -1813,6 +1813,40 @@ } } }, + "native-alternative-payment.confirm-capture-button.title" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لقد دفعت" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I’ve paid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "J'ai payé" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Płatność wykonana" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Já paguei" + } + } + } + }, "native-alternative-payment.email.placeholder" : { "localizations" : { "ar" : { diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfirmation.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfirmation.swift index 22df337db..462bb13f3 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfirmation.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Configuration/PONativeAlternativePaymentConfirmation.swift @@ -10,6 +10,18 @@ import Foundation /// Configuration specific to native APM payment confirmation. public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint:disable:this type_name + /// Confirmation button configuration. + public struct ConfirmButton { + + /// Button title. + public let title: String? + + /// Creates button instance. + public init(title: String? = nil) { + self.title = title + } + } + /// Boolean value that specifies whether module should wait for payment confirmation from PSP or will /// complete right after all user's input is submitted. Default value is `true`. public let waitsConfirmation: Bool @@ -26,6 +38,13 @@ public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint /// Default value is `false`. public let hideGatewayDetails: Bool + /// 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 secondaryAction: PONativeAlternativePaymentConfiguration.SecondaryAction? @@ -36,12 +55,14 @@ public struct PONativeAlternativePaymentConfirmationConfiguration { // swiftlint timeout: TimeInterval = 180, showProgressIndicatorAfter: TimeInterval? = nil, hideGatewayDetails: Bool = false, + confirmButton: ConfirmButton? = nil, secondaryAction: PONativeAlternativePaymentConfiguration.SecondaryAction? = nil ) { self.waitsConfirmation = waitsConfirmation self.timeout = timeout self.showProgressIndicatorAfter = showProgressIndicatorAfter self.hideGatewayDetails = hideGatewayDetails + self.confirmButton = confirmButton self.secondaryAction = secondaryAction } } diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift index 3f5e88913..98da642c2 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentDefaultInteractor.swift @@ -86,13 +86,22 @@ final class NativeAlternativePaymentDefaultInteractor: } } + func confirmCapture() { + confirmCapture(force: false) + } + override func cancel() { + // todo(andrii-vysotskyi): allow cancellation in all states except sink logger.debug("Will attempt to cancel payment.") switch state { case .started(let state) where state.isCancellable: setFailureStateUnchecked(error: POFailure(code: .cancelled)) case .awaitingCapture(let state) where state.isCancellable: - captureCancellable?.cancel() + if let cancellable = state.cancellable { + cancellable.cancel() + } else { + setFailureStateUnchecked(error: POFailure(code: .cancelled)) + } default: logger.debug("Ignored cancellation attempt from unsupported state: \(state)") } @@ -117,8 +126,6 @@ final class NativeAlternativePaymentDefaultInteractor: private let logger: POLogger private let completion: (Result) -> Void - private var captureCancellable: AnyCancellable? - // MARK: - Starting State @MainActor @@ -140,8 +147,7 @@ final class NativeAlternativePaymentDefaultInteractor: } let startedState = State.Started( gateway: details.gateway, - amount: details.invoice.amount, - currencyCode: details.invoice.currencyCode, + invoice: details.invoice, parameters: await createParameters(specifications: details.parameters), isCancellable: disableDuration(of: configuration.secondaryAction).isZero ) @@ -213,36 +219,58 @@ final class NativeAlternativePaymentDefaultInteractor: setSubmittedUnchecked() return } - let actionMessage = parameterValues?.customerActionMessage ?? gateway.customerActionMessage - send(event: .willWaitForCaptureConfirmation(additionalActionExpected: actionMessage != nil)) + let customerActionMessage = parameterValues?.customerActionMessage ?? gateway.customerActionMessage + let additionalActionExpected = customerActionMessage != nil + send(event: .willWaitForCaptureConfirmation(additionalActionExpected: additionalActionExpected)) let (logoImage, actionImage) = await imagesRepository.images( at: logoUrl(gateway: gateway, parameterValues: parameterValues), gateway.customerActionImageUrl ) + let shouldConfirmCapture = additionalActionExpected && configuration.paymentConfirmation.confirmButton != nil let awaitingCaptureState = State.AwaitingCapture( - paymentProviderName: parameterValues?.providerName, - logoImage: logoImage, - actionMessage: actionMessage, - actionImage: actionImage, + paymentProvider: .init(name: parameterValues?.providerName, image: logoImage), + customerAction: customerActionMessage.map { message in + .init(message: message, image: actionImage) + }, isCancellable: disableDuration(of: configuration.paymentConfirmation.secondaryAction).isZero, - isDelayed: false + isDelayed: false, + shouldConfirmCapture: shouldConfirmCapture ) setStateUnchecked(.awaitingCapture(awaitingCaptureState)) - logger.info("Waiting for invoice capture confirmation") + if !shouldConfirmCapture { + confirmCapture(force: true) + } + enableCaptureCancellationAfterDelay() + } + + private func confirmCapture(force: Bool) { + guard case .awaitingCapture(var currentState) = state else { + logger.debug("Ignoring attempt to confirm capture from unsupported state: \(state).") + return + } + guard (currentState.shouldConfirmCapture || force) && currentState.cancellable == nil else { + logger.debug("Payment is already being captured, ignored.") + return + } + if !force { + delegate?.nativeAlternativePaymentMethodDidEmitEvent(.didConfirmPayment) + } let request = PONativeAlternativePaymentCaptureRequest( invoiceId: configuration.invoiceId, gatewayConfigurationId: configuration.gatewayConfigurationId, timeout: configuration.paymentConfirmation.timeout ) - let task = Task { + let task = Task { @MainActor in do { try await invoicesService.captureNativeAlternativePayment(request: request) - await setCapturedStateUnchecked(gateway: gateway, parameterValues: parameterValues) + await setCapturedStateUnchecked(paymentProvider: currentState.paymentProvider) } catch { setFailureStateUnchecked(error: error) } } - captureCancellable = AnyCancellable(task.cancel) - enableCaptureCancellationAfterDelay() + currentState.cancellable = AnyCancellable(task.cancel) + currentState.shouldConfirmCapture = false + self.state = .awaitingCapture(currentState) + logger.info("Waiting for invoice capture confirmation") schedulePaymentConfirmationDelay() } @@ -265,6 +293,17 @@ final class NativeAlternativePaymentDefaultInteractor: private func setCapturedStateUnchecked( gateway: PONativeAlternativePaymentMethodTransactionDetails.Gateway, parameterValues: PONativeAlternativePaymentMethodParameterValues? + ) async { + let logoImage = await imagesRepository.image( + at: logoUrl(gateway: gateway, parameterValues: parameterValues) + ) + let paymentProvider = State.PaymentProvider(name: parameterValues?.providerName, image: logoImage) + await setCapturedStateUnchecked(paymentProvider: paymentProvider) + } + + @MainActor + private func setCapturedStateUnchecked( + paymentProvider: NativeAlternativePaymentInteractorState.PaymentProvider ) async { logger.info("Did receive invoice capture confirmation") guard configuration.paymentConfirmation.waitsConfirmation else { @@ -272,17 +311,7 @@ final class NativeAlternativePaymentDefaultInteractor: setSubmittedUnchecked() return } - let capturedState: State.Captured - if case .awaitingCapture(let awaitingCaptureState) = state { - capturedState = State.Captured( - paymentProviderName: awaitingCaptureState.paymentProviderName, logoImage: awaitingCaptureState.logoImage - ) - } else { - let logoImage = await imagesRepository.image( - at: logoUrl(gateway: gateway, parameterValues: parameterValues) - ) - capturedState = State.Captured(paymentProviderName: parameterValues?.providerName, logoImage: logoImage) - } + let capturedState = State.Captured(paymentProvider: paymentProvider) setStateUnchecked(.captured(capturedState)) send(event: .didCompletePayment) if !configuration.skipSuccessScreen { @@ -370,14 +399,13 @@ final class NativeAlternativePaymentDefaultInteractor: // MARK: - Cancellation Availability - @MainActor private func enableCancellationAfterDelay() { let disabledFor = disableDuration(of: configuration.secondaryAction) guard disabledFor > 0 else { logger.debug("Cancel action is not set or initially enabled.") return } - Task { + Task { @MainActor in try? await Task.sleep(seconds: disabledFor) switch state { case .started(var state): @@ -392,14 +420,13 @@ final class NativeAlternativePaymentDefaultInteractor: } } - @MainActor private func enableCaptureCancellationAfterDelay() { let disabledFor = disableDuration(of: configuration.paymentConfirmation.secondaryAction) guard disabledFor > 0 else { logger.debug("Confirmation cancel action is not set or initially enabled.") return } - Task { + Task { @MainActor in try? await Task.sleep(seconds: disabledFor) guard case .awaitingCapture(var awaitingState) = state else { return diff --git a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractor.swift b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractor.swift index 833e28571..e6d22754d 100644 --- a/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractor.swift +++ b/Sources/ProcessOutUI/Sources/Modules/NativeAlternativePayment/Interactor/NativeAlternativePaymentInteractor.swift @@ -19,6 +19,11 @@ protocol NativeAlternativePaymentInteractor: Interactor [NativeAlternativePaymentViewModelSection] { let item: NativeAlternativePaymentViewModelItem - if let expectedActionMessage = state.actionMessage { + if let customerAction = state.customerAction { let submittedItem = NativeAlternativePaymentViewModelItem.Submitted( id: "awaiting-capture", - title: state.logoImage == nil ? state.paymentProviderName : nil, - logoImage: state.logoImage, - message: expectedActionMessage, - isMessageCompact: expectedActionMessage.count <= Constants.maximumCompactMessageLength, - image: state.actionImage, + title: state.paymentProvider.image == nil ? state.paymentProvider.name : nil, + logoImage: state.paymentProvider.image, + message: customerAction.message, + isMessageCompact: customerAction.message.count <= Constants.maximumCompactMessageLength, + image: customerAction.image, isCaptured: false, isProgressViewHidden: !state.isDelayed ) item = .submitted(submittedItem) } else { + // todo(andrii-vysotskyi): set additional text saying that payment is being processed. item = .progress } let section = NativeAlternativePaymentViewModelSection( @@ -214,13 +215,36 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { private func createActions(state: InteractorState.AwaitingCapture) -> [POActionsContainerActionViewModel] { let actions = [ + createConfirmPaymentCaptureAction(state: state), cancelAction( - configuration: configuration.paymentConfirmation.secondaryAction, isEnabled: state.isCancellable + configuration: configuration.paymentConfirmation.secondaryAction, + isEnabled: state.isCancellable ) ] return actions.compactMap { $0 } } + private func createConfirmPaymentCaptureAction( + state: InteractorState.AwaitingCapture + ) -> POActionsContainerActionViewModel? { + guard state.shouldConfirmCapture else { + return nil + } + let buttonTitle = interactor.configuration.paymentConfirmation.confirmButton?.title + ?? String(resource: .NativeAlternativePayment.Button.confirmCapture) + let action = POActionsContainerActionViewModel( + id: "native-alternative-payment.primary-button", + title: buttonTitle, + isEnabled: true, + isLoading: false, + isPrimary: true, + action: { [weak self] in + self?.interactor.confirmCapture() + } + ) + return action + } + // MARK: - Captured State private func update(with state: InteractorState.Captured) { @@ -237,8 +261,8 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { private func createSections(state: InteractorState.Captured) -> [NativeAlternativePaymentViewModelSection] { let item = NativeAlternativePaymentViewModelItem.Submitted( id: "captured", - title: state.logoImage == nil ? state.paymentProviderName : nil, - logoImage: state.logoImage, + title: state.paymentProvider.image == nil ? state.paymentProvider.name : nil, + logoImage: state.paymentProvider.image, message: configuration.successMessage ?? String(resource: .NativeAlternativePayment.Success.message), isMessageCompact: true, image: UIImage(poResource: .success).withRenderingMode(.alwaysTemplate), @@ -368,9 +392,9 @@ final class DefaultNativeAlternativePaymentViewModel: ViewModel { if let customTitle = configuration.primaryActionTitle { title = customTitle } else { - priceFormatter.currencyCode = state.currencyCode + priceFormatter.currencyCode = state.invoice.currencyCode // swiftlint:disable:next legacy_objc_type - if let formattedAmount = priceFormatter.string(from: state.amount as NSDecimalNumber) { + if let formattedAmount = priceFormatter.string(from: state.invoice.amount as NSDecimalNumber) { title = String(resource: .NativeAlternativePayment.Button.submitAmount, replacements: formattedAmount) } else { title = String(resource: .NativeAlternativePayment.Button.submit)