Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}


Expand Down Expand Up @@ -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 {
Expand All @@ -296,6 +302,7 @@ extension StripeCardReaderService: CardReaderService {
self?.activePaymentIntent = nil
promise(.success(()))
}
self?.cancellationStartedInApp = nil
}
}
guard let paymentCancellable = self.paymentCancellable,
Expand Down Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 18 additions & 4 deletions Hardware/Hardware/CardReader/UnderlyingError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion Hardware/HardwareTests/ErrorCodesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,7 @@ final class CollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProtocol {
onCompleted: onCompleted)
})
case .canceled:
self.alertsPresenter.dismiss()
self.trackPaymentCancelation()
self.handlePaymentCancellation()
onCancel()
case .none:
break
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down