diff --git a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index edad952d282..7cb5c1ca31c 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -32,6 +32,10 @@ public final class StripeCardReaderService: NSObject { /// Keeps track of whether a chip card needs to be removed private var timerCancellable: Cancellable? private var isChipCardInserted: Bool = false + + /// Stripe don't tell us where a cancellation comes from: if we keep track of when we trigger one, + /// we can infer when it comes from the cancel button on the reader instead + private var cancellationStartedInApp: Bool? } @@ -285,6 +289,8 @@ extension StripeCardReaderService: CardReaderService { return } + self.cancellationStartedInApp = true + let cancelPaymentIntent = { [weak self] in Terminal.shared.cancelPaymentIntent(activePaymentIntent) { (intent, error) in if let error = error { @@ -296,6 +302,7 @@ extension StripeCardReaderService: CardReaderService { self?.activePaymentIntent = nil promise(.success(())) } + self?.cancellationStartedInApp = nil } } guard let paymentCancellable = self.paymentCancellable, @@ -502,27 +509,29 @@ private extension StripeCardReaderService { /// Because we are chaining promises, we need to retain a reference /// to this cancellable if we want to cancel self?.paymentCancellable = Terminal.shared.collectPaymentMethod(intent) { (intent, error) in - self?.paymentCancellable = nil - if let error = error { - let underlyingError = UnderlyingError(with: error) + var underlyingError = UnderlyingError(with: error) /// the completion block for collectPaymentMethod will be called /// with error Canceled when collectPaymentMethod is canceled /// https://stripe.dev/stripe-terminal-ios/docs/Classes/SCPTerminal.html#/c:objc(cs)SCPTerminal(im)collectPaymentMethod:delegate:completion: - - if underlyingError != .commandCancelled { + if case .commandCancelled(let cancellationSource) = underlyingError { + DDLogWarn("💳 Warning: collect payment cancelled \(error)") + if case .unknown = cancellationSource { + if self?.cancellationStartedInApp != nil { + underlyingError = .commandCancelled(from: .app) + } else { + underlyingError = .commandCancelled(from: .reader) + } + } + } else { DDLogError("💳 Error: collect payment method \(underlyingError)") - promise(.failure(CardReaderServiceError.paymentMethodCollection(underlyingError: underlyingError))) - } - - if underlyingError == .commandCancelled { - DDLogWarn("💳 Warning: collect payment error cancelled. We actively ignore this error \(error)") - promise(.failure(CardReaderServiceError.paymentCancellation(underlyingError: underlyingError))) } - + self?.paymentCancellable = nil + promise(.failure(CardReaderServiceError.paymentMethodCollection(underlyingError: underlyingError))) } if let intent = intent { + self?.paymentCancellable = nil self?.sendReaderEvent(.cardDetailsCollected) promise(.success(intent)) } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift b/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift index 915699280d0..6a17582593c 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift @@ -26,7 +26,7 @@ extension UnderlyingError { case ErrorCode.Code.featureNotAvailableWithConnectedReader.rawValue: self = .featureNotAvailableWithConnectedReader case ErrorCode.Code.canceled.rawValue: - self = .commandCancelled + self = .commandCancelled(from: .unknown) case ErrorCode.Code.locationServicesDisabled.rawValue: self = .locationServicesDisabled case ErrorCode.Code.bluetoothDisabled.rawValue: diff --git a/Hardware/Hardware/CardReader/UnderlyingError.swift b/Hardware/Hardware/CardReader/UnderlyingError.swift index 902f38c6183..54f8e55383c 100644 --- a/Hardware/Hardware/CardReader/UnderlyingError.swift +++ b/Hardware/Hardware/CardReader/UnderlyingError.swift @@ -27,7 +27,15 @@ public enum UnderlyingError: Error, Equatable { case featureNotAvailableWithConnectedReader /// A command was cancelled - case commandCancelled + case commandCancelled(from: CancellationSource) + + /// A command can be cancelled on the reader, or in the app. + /// Note that this is not produced by Stripe, we have to infer it from commandCancelled, so we start with `.unknown`. + public enum CancellationSource { + case unknown + case app + case reader + } /// Access to location services is currently disabled. This may be because: /// - The user disabled location services in the system settings. @@ -282,9 +290,15 @@ extension UnderlyingError: LocalizedError { case .featureNotAvailableWithConnectedReader: return NSLocalizedString("Unable to perform request with the connected reader - unsupported feature - please try again with another reader", comment: "Error message when the card reader cannot be used to perform the requested task.") - case .commandCancelled: - return NSLocalizedString("The system canceled the command unexpectedly - please try again", - comment: "Error message when the system cancels a command.") + case .commandCancelled(let cancellationSource): + switch cancellationSource { + case .reader: + return NSLocalizedString("The payment was canceled on the reader", + comment: "Error message when the cancel button on the reader is used.") + default: + return NSLocalizedString("The system canceled the command unexpectedly - please try again", + comment: "Error message when the system cancels a command.") + } case .locationServicesDisabled: return NSLocalizedString("Unable to access Location Services - please enable Location Services and try again", comment: "Error message when location services is not enabled for this application.") diff --git a/Hardware/HardwareTests/ErrorCodesTests.swift b/Hardware/HardwareTests/ErrorCodesTests.swift index b97d14ea6ba..80a35854f36 100644 --- a/Hardware/HardwareTests/ErrorCodesTests.swift +++ b/Hardware/HardwareTests/ErrorCodesTests.swift @@ -39,7 +39,7 @@ final class CardReaderServiceErrorTests: XCTestCase { } func test_stripe_cancelled_maps_to_expected_error() { - XCTAssertEqual(.commandCancelled, domainError(stripeCode: 2020)) + XCTAssertEqual(.commandCancelled(from: .unknown), domainError(stripeCode: 2020)) } func test_stripe_location_services_disabled_maps_to_expected_error() { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderPaymentAlertsProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderPaymentAlertsProvider.swift index 5a83bb3140e..ef32090f74d 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderPaymentAlertsProvider.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothCardReaderPaymentAlertsProvider.swift @@ -87,6 +87,12 @@ final class BluetoothCardReaderPaymentAlertsProvider: CardReaderTransactionAlert func retryableError(tryAgain: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { CardPresentModalRetryableError(primaryAction: tryAgain) } + + func cancelledOnReader() -> CardPresentPaymentsModalViewModel? { + CardPresentModalNonRetryableError(amount: amount, + error: CardReaderServiceError.paymentMethodCollection(underlyingError: .commandCancelled(from: .reader)), + onDismiss: { }) + } } private extension BluetoothCardReaderPaymentAlertsProvider { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift index 92181ab7677..31657b4629c 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift @@ -79,6 +79,10 @@ final class BuiltInCardReaderPaymentAlertsProvider: CardReaderTransactionAlertsP func retryableError(tryAgain: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { CardPresentModalRetryableError(primaryAction: tryAgain) } + + func cancelledOnReader() -> CardPresentPaymentsModalViewModel? { + return nil + } } private extension BuiltInCardReaderPaymentAlertsProvider { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderTransactionAlertsProviding.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderTransactionAlertsProviding.swift index 9a12c6b7ac4..67af4238aff 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderTransactionAlertsProviding.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderTransactionAlertsProviding.swift @@ -45,4 +45,8 @@ protocol CardReaderTransactionAlertsProviding { /// An alert to display a retriable error /// func retryableError(tryAgain: @escaping () -> Void) -> CardPresentPaymentsModalViewModel + + /// An alert to notify the merchant that the transaction was cancelled using a button on the reader + /// + func cancelledOnReader() -> CardPresentPaymentsModalViewModel? } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index 286abef11e1..89f4d87a982 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -164,8 +164,7 @@ final class CollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProtocol { onCompleted: onCompleted) }) case .canceled: - self.alertsPresenter.dismiss() - self.trackPaymentCancelation() + self.handlePaymentCancellation() onCancel() case .none: break @@ -275,6 +274,13 @@ private extension CollectOrderPaymentUseCase { switch result { case .success(let capturedPaymentData): self?.handleSuccessfulPayment(capturedPaymentData: capturedPaymentData, onCompletion: onCompletion) + case .failure(CardReaderServiceError.paymentMethodCollection(.commandCancelled(let cancellationSource))): + switch cancellationSource { + case .reader: + self?.handlePaymentCancellationFromReader(alertProvider: paymentAlerts) + default: + self?.handlePaymentCancellation() + } case .failure(let error): self?.handlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, onCompletion: onCompletion) } @@ -297,6 +303,19 @@ private extension CollectOrderPaymentUseCase { onCompletion(.success(capturedPaymentData)) } + func handlePaymentCancellation() { + trackPaymentCancelation() + alertsPresenter.dismiss() + } + + func handlePaymentCancellationFromReader(alertProvider paymentAlerts: CardReaderTransactionAlertsProviding) { + trackPaymentCancelation() + guard let dismissedOnReaderAlert = paymentAlerts.cancelledOnReader() else { + return alertsPresenter.dismiss() + } + alertsPresenter.present(viewModel: dismissedOnReaderAlert) + } + /// Log the failure reason, cancel the current payment and retry it if possible. /// func handlePaymentFailureAndRetryPayment(_ error: Error, diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/LegacyCollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/LegacyCollectOrderPaymentUseCase.swift index 21bcbf9246e..2bbbfadf1e5 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/LegacyCollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/LegacyCollectOrderPaymentUseCase.swift @@ -293,6 +293,9 @@ private extension LegacyCollectOrderPaymentUseCase { switch result { case .success(let capturedPaymentData): self?.handleSuccessfulPayment(capturedPaymentData: capturedPaymentData, onCompletion: onCompletion) + case .failure(CardReaderServiceError.paymentMethodCollection(.commandCancelled(_))): + self?.trackPaymentCancelation() + onCompletion(.failure(CollectOrderPaymentUseCaseError.flowCanceledByUser)) case .failure(let error): self?.handlePaymentFailureAndRetryPayment(error, onCompletion: onCompletion) }