diff --git a/Hardware/Hardware/CardReader/CardReaderDiscoveryMethod.swift b/Hardware/Hardware/CardReader/CardReaderDiscoveryMethod.swift index 5e8d38b6d36..e851b41cffb 100644 --- a/Hardware/Hardware/CardReader/CardReaderDiscoveryMethod.swift +++ b/Hardware/Hardware/CardReader/CardReaderDiscoveryMethod.swift @@ -2,5 +2,6 @@ import Foundation public enum CardReaderDiscoveryMethod { case localMobile + case remoteMobile case bluetoothScan } diff --git a/Hardware/Hardware/CardReader/CardReaderEvent.swift b/Hardware/Hardware/CardReader/CardReaderEvent.swift index 6fc302ce492..beedb9dcb2d 100644 --- a/Hardware/Hardware/CardReader/CardReaderEvent.swift +++ b/Hardware/Hardware/CardReader/CardReaderEvent.swift @@ -1,5 +1,5 @@ /// The possible events from a connected reader. -public enum CardReaderEvent: Equatable { +public enum CardReaderEvent: Equatable, Codable { /// The reader begins waiting for input. /// The app should prompt the customer to present a payment method case waitingForInput(CardReaderInput) diff --git a/Hardware/Hardware/CardReader/CardReaderInputOptions.swift b/Hardware/Hardware/CardReader/CardReaderInputOptions.swift index 399427bc8de..d1e320db70b 100644 --- a/Hardware/Hardware/CardReader/CardReaderInputOptions.swift +++ b/Hardware/Hardware/CardReader/CardReaderInputOptions.swift @@ -1,6 +1,6 @@ import Foundation -public struct CardReaderInput: OptionSet { +public struct CardReaderInput: OptionSet, Codable { public let rawValue: Int public init(rawValue: Int) { diff --git a/Hardware/Hardware/CardReader/CardReaderType.swift b/Hardware/Hardware/CardReader/CardReaderType.swift index 25da0b376da..7f98708002c 100644 --- a/Hardware/Hardware/CardReader/CardReaderType.swift +++ b/Hardware/Hardware/CardReader/CardReaderType.swift @@ -9,6 +9,8 @@ public enum CardReaderType: String, CaseIterable { case wisepad3 /// Tap on Mobile: Apple built in reader case appleBuiltIn + /// Tap on Mobile: Apple built in reader over a peer to peer network + case remoteTapToPay /// Other case other } @@ -30,6 +32,8 @@ extension CardReaderType { return "WISEPAD_3" case .appleBuiltIn: return "COTS_DEVICE" + case .remoteTapToPay: + return "REMOTE_COTS_DEVICE" default: return "UNKNOWN" } diff --git a/Hardware/Hardware/CardReader/Charge.swift b/Hardware/Hardware/CardReader/Charge.swift index 8c51401c9dd..9754a515b78 100644 --- a/Hardware/Hardware/CardReader/Charge.swift +++ b/Hardware/Hardware/CardReader/Charge.swift @@ -1,7 +1,7 @@ import Codegen /// A struct representing a charge. -public struct Charge: Identifiable, GeneratedCopiable, GeneratedFakeable { +public struct Charge: Identifiable, GeneratedCopiable, GeneratedFakeable, Codable { /// The unique identifier for the charge. public let id: String diff --git a/Hardware/Hardware/CardReader/ChargeStatus.swift b/Hardware/Hardware/CardReader/ChargeStatus.swift index 31c9ff7728b..4b11cc6ecfd 100644 --- a/Hardware/Hardware/CardReader/ChargeStatus.swift +++ b/Hardware/Hardware/CardReader/ChargeStatus.swift @@ -1,7 +1,7 @@ import Codegen /// The possible statuses for a charge -public enum ChargeStatus: Equatable, GeneratedFakeable { +public enum ChargeStatus: Equatable, GeneratedFakeable, Codable { /// The charge succeeded. case succeeded diff --git a/Hardware/Hardware/CardReader/PaymentIntent.swift b/Hardware/Hardware/CardReader/PaymentIntent.swift index 19d0b4cf088..2356506f2f7 100644 --- a/Hardware/Hardware/CardReader/PaymentIntent.swift +++ b/Hardware/Hardware/CardReader/PaymentIntent.swift @@ -2,7 +2,7 @@ import Codegen /// A PaymentIntent tracks the process of collecting a payment from your customer. /// We would create exactly one PaymentIntent for each order -public struct PaymentIntent: Identifiable, GeneratedCopiable, GeneratedFakeable { +public struct PaymentIntent: Identifiable, GeneratedCopiable, GeneratedFakeable, Codable { /// Unique identifier for the PaymentIntent public let id: String diff --git a/Hardware/Hardware/CardReader/PaymentIntentStatus.swift b/Hardware/Hardware/CardReader/PaymentIntentStatus.swift index d9b2490aa0b..3ebd8da2059 100644 --- a/Hardware/Hardware/CardReader/PaymentIntentStatus.swift +++ b/Hardware/Hardware/CardReader/PaymentIntentStatus.swift @@ -1,7 +1,7 @@ import Codegen /// The possible statuses for a PaymentIntent. -public enum PaymentIntentStatus: Equatable, GeneratedFakeable { +public enum PaymentIntentStatus: Equatable, GeneratedFakeable, Codable { case requiresPaymentMethod case requiresConfirmation case requiresCapture diff --git a/Hardware/Hardware/CardReader/PaymentMethod.swift b/Hardware/Hardware/CardReader/PaymentMethod.swift index 0df173660d2..801a35de303 100644 --- a/Hardware/Hardware/CardReader/PaymentMethod.swift +++ b/Hardware/Hardware/CardReader/PaymentMethod.swift @@ -1,7 +1,7 @@ import Codegen /// The type of the PaymentMethod. -public enum PaymentMethod: Equatable, GeneratedFakeable { +public enum PaymentMethod: Equatable, GeneratedFakeable, Codable { /// A card payment method. case card diff --git a/Hardware/Hardware/CardReader/StripeCardReader/CardReader+CardReaderDiscoveryType.swift b/Hardware/Hardware/CardReader/StripeCardReader/CardReader+CardReaderDiscoveryType.swift index 0338bd9691b..af56fc97940 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/CardReader+CardReaderDiscoveryType.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/CardReader+CardReaderDiscoveryType.swift @@ -7,6 +7,8 @@ public extension CardReader { return .localMobile case .chipper, .stripeM2, .wisepad3: return .bluetoothScan + case .remoteTapToPay: + return .remoteMobile case .other: return nil } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod+Stripe.swift b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod+Stripe.swift index 0b9d785728e..56086ef194f 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod+Stripe.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod+Stripe.swift @@ -4,7 +4,7 @@ import StripeTerminal public extension CardReaderDiscoveryMethod { func toStripe() -> DiscoveryMethod { switch self { - case .localMobile: + case .localMobile, .remoteMobile: return .localMobile case .bluetoothScan: return .bluetoothScan diff --git a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift index dc264833a64..9ff0efe16ca 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift @@ -30,7 +30,8 @@ extension CardReaderType { return .wisePad3 case .appleBuiltIn: return .appleBuiltIn - case .other: + case .remoteTapToPay, + .other: return nil } } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index fc8644ae50f..869f8504896 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -134,7 +134,7 @@ extension StripeCardReaderService: CardReaderService { DDLogError("\(error)") throw error } - case .localMobile: + case .localMobile, .remoteMobile: let localMobileConfig = LocalMobileDiscoveryConfigurationBuilder() do { config = try localMobileConfig.setSimulated(shouldUseSimulatedCardReader).build() diff --git a/Storage/Storage/Model/CardReaderType.swift b/Storage/Storage/Model/CardReaderType.swift index d5b847724f3..e7ae50e4fa9 100644 --- a/Storage/Storage/Model/CardReaderType.swift +++ b/Storage/Storage/Model/CardReaderType.swift @@ -11,6 +11,7 @@ public enum CardReaderType: String, Codable { case wisepad3 /// Tap on Mobile: Apple built in reader case appleBuiltIn + case remoteTapToPay /// Other case other } diff --git a/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.swift b/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.swift index 5831cbc8671..7cb37fa2a7f 100644 --- a/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.swift +++ b/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import UIKit import WordPressAuthenticator import Experiments @@ -33,6 +34,7 @@ final class LoginPrologueViewController: UIViewController { return .portrait } + private var tapToPayServer: RemoteTapToPayReaderServer? // MARK: - Overridden Methods @@ -57,6 +59,17 @@ final class LoginPrologueViewController: UIViewController { setupCarousel() } + @IBAction func remoteTapToPayTapped(_ sender: Any) { + guard tapToPayServer == nil else { + return + } + let tapToPayServer = RemoteTapToPayReaderServer() + tapToPayServer.start() + self.tapToPayServer = tapToPayServer + let hostingController = UIHostingController(rootView: RemoteTapToPayServerView(server: tapToPayServer)) + present(hostingController, animated: true) + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) diff --git a/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.xib b/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.xib index 6ef9b2a1392..8286fd99768 100644 --- a/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.xib +++ b/WooCommerce/Classes/Authentication/Prologue/LoginPrologueViewController.xib @@ -1,9 +1,9 @@ - + - + @@ -41,6 +41,15 @@ + diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentInvalidatablePaymentOrchestrator.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentInvalidatablePaymentOrchestrator.swift index 997ebf5e8e8..43e321e06e5 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentInvalidatablePaymentOrchestrator.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentInvalidatablePaymentOrchestrator.swift @@ -10,6 +10,7 @@ final class CardPresentPaymentInvalidatablePaymentOrchestrator: PaymentCaptureOr } func collectPayment(for order: Order, + using cardReader: CardReader, orderTotal: NSDecimalNumber, paymentGatewayAccount: PaymentGatewayAccount, paymentMethodTypes: [String], @@ -24,6 +25,7 @@ final class CardPresentPaymentInvalidatablePaymentOrchestrator: PaymentCaptureOr return onCompletion(.failure(CardPresentPaymentInvalidatablePaymentOrchestratorError.paymentInvalidated)) } paymentOrchestrator.collectPayment(for: order, + using: cardReader, orderTotal: orderTotal, paymentGatewayAccount: paymentGatewayAccount, paymentMethodTypes: paymentMethodTypes, diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentReaderConnectionResult.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentReaderConnectionResult.swift index cb8ea85b783..c8aa3080429 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentReaderConnectionResult.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentReaderConnectionResult.swift @@ -14,6 +14,8 @@ extension CardReaderConnectionMethod { return .bluetoothScan case .tapToPay: return .localMobile + case .remoteTapToPay: + return .remoteMobile } } } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentService.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentService.swift index 3d96bf7a8d4..c3d9af6cacd 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentService.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentService.swift @@ -91,6 +91,9 @@ final class CardPresentPaymentService: CardPresentPaymentFacade { paymentEventSubject.send(.show(eventDetails: .connectionSuccess(done: { [weak self] in self?.paymentEventSubject.send(.idle) }))) + if connectionMethod == .remoteTapToPay { + readerConnectionStatusSubject.send(.connected(connectedReader)) + } return .connected(connectedReader) case .canceled: readerConnectionStatusSubject.send(.disconnected) diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsConnectionControllerManager.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsConnectionControllerManager.swift index 02deef708fb..deecaa4b1fd 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsConnectionControllerManager.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsConnectionControllerManager.swift @@ -4,7 +4,7 @@ import struct Yosemite.CardPresentPaymentsConfiguration final class CardPresentPaymentsConnectionControllerManager { let externalReaderConnectionController: CardReaderConnectionController - let tapToPayConnectionController: BuiltInCardReaderConnectionController let analyticsTracker: CardReaderConnectionAnalyticsTracker @@ -27,11 +27,10 @@ final class CardPresentPaymentsConnectionControllerManager { alertsProvider: CardPresentPaymentBluetoothReaderConnectionAlertsProvider(), configuration: configuration, analyticsTracker: analyticsTracker) - self.tapToPayConnectionController = BuiltInCardReaderConnectionController( + self.tapToPayConnectionController = RemoteBuiltInCardReaderConnectionController( forSiteID: siteID, alertsPresenter: alertsPresenter, alertsProvider: CardPresentPaymentBuiltInReaderConnectionAlertsProvider(), - configuration: configuration, - analyticsTracker: analyticsTracker) + configuration: configuration) } } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardReaderConnectionMethod.swift b/WooCommerce/Classes/POS/Card Present Payments/CardReaderConnectionMethod.swift index 8db262fc112..f6a1b926fd4 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardReaderConnectionMethod.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardReaderConnectionMethod.swift @@ -3,4 +3,5 @@ import Foundation enum CardReaderConnectionMethod { case bluetooth case tapToPay + case remoteTapToPay } diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift index 8be89e5047b..05039662e71 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionStatusView.swift @@ -43,8 +43,17 @@ struct CardReaderConnectionStatusView: View { case .cancellingConnection: progressIndicatingCardReaderStatus(title: Localization.pleaseWait) case .disconnected: - Button { - connectionViewModel.connectReader() + Menu { + Button { + connectionViewModel.connectReader() + } label: { + Text("Bluetooth reader") + } + Button { + connectionViewModel.connectRemoteReader() + } label: { + Text("Tap to Pay on iPhone (remote)") + } } label: { HStack(spacing: Constants.buttonImageAndTextSpacing) { circleIcon(with: Color(.wooCommerceAmber(.shade60))) diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionViewModel.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionViewModel.swift index 7d3909093c2..a22e0f6f355 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionViewModel.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/CardReaderConnectionViewModel.swift @@ -22,6 +22,19 @@ final class CardReaderConnectionViewModel: ObservableObject { } } + func connectRemoteReader() { + guard connectionStatus == .disconnected else { + return + } + Task { @MainActor in + do { + let _ = try await cardPresentPayment.connectReader(using: .remoteTapToPay) + } catch { + DDLogError("🔴 POS tap to pay connection error: \(error)") + } + } + } + func disconnectReader() { guard case .connected = connectionStatus else { return diff --git a/WooCommerce/Classes/POS/ViewModels/TotalsViewModel.swift b/WooCommerce/Classes/POS/ViewModels/TotalsViewModel.swift index 42099ca636e..c18a3fdfd5a 100644 --- a/WooCommerce/Classes/POS/ViewModels/TotalsViewModel.swift +++ b/WooCommerce/Classes/POS/ViewModels/TotalsViewModel.swift @@ -110,7 +110,7 @@ final class TotalsViewModel: ObservableObject, TotalsViewModelProtocol { func connectReaderTapped() { Task { @MainActor in do { - let _ = try await cardPresentPaymentService.connectReader(using: .bluetooth) + let _ = try await cardPresentPaymentService.connectReader(using: .remoteTapToPay) } catch { DDLogError("🔴 POS reader connection error: \(error)") } @@ -236,7 +236,7 @@ private extension TotalsViewModel { @MainActor func collectPayment(for order: Order) async throws { - _ = try await cardPresentPaymentService.collectPayment(for: order, using: .bluetooth) + _ = try await cardPresentPaymentService.collectPayment(for: order, using: .remoteTapToPay) } } diff --git a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift index 1e78ded0092..63deb7b27f7 100644 --- a/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift +++ b/WooCommerce/Classes/ServiceLocator/ServiceLocator.swift @@ -81,6 +81,8 @@ final class ServiceLocator { private static var _cardReaderConfigProvider: CommonReaderConfigProviding = CommonReaderConfigProvider() + private static var _remoteCardReaderClient: RemoteTapToPayReaderClient = RemoteTapToPayReaderClient() + /// Support for printing receipts /// private static var _receiptPrinter: PrinterService = AirPrintReceiptPrinterService() @@ -228,6 +230,10 @@ final class ServiceLocator { _cardReaderConfigProvider } + static var remoteCardReaderClient: RemoteTapToPayReaderClient { + _remoteCardReaderClient + } + /// Provides the access point to the ReceiptPrinterService. /// - Returns: An implementation of the ReceiptPrinterService protocol. static var receiptPrinterService: PrinterService { diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalSelectSearchType.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalSelectSearchType.swift index 8a9a61ac076..e327246ccb6 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalSelectSearchType.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalSelectSearchType.swift @@ -81,7 +81,7 @@ private extension CardReaderDiscoveryMethod { return NSLocalizedString( "Bluetooth Reader", comment: "The button title on the reader type alert, for the user to choose a bluetooth reader.") - case .localMobile: + case .localMobile, .remoteMobile: return NSLocalizedString( "Tap to Pay on iPhone", comment: "The button title on the reader type alert, for the user to choose Tap to Pay on iPhone.") diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift index 38fc87cdd75..3769c8ba33b 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift @@ -13,6 +13,7 @@ struct CardPresentCapturedPaymentData { protocol PaymentCaptureOrchestrating { func collectPayment(for order: Order, + using reader: CardReader, orderTotal: NSDecimalNumber, paymentGatewayAccount: PaymentGatewayAccount, paymentMethodTypes: [String], @@ -49,6 +50,8 @@ final class PaymentCaptureOrchestrator: PaymentCaptureOrchestrating { private let personNameComponentsFormatter = PersonNameComponentsFormatter() private let paymentReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer + private let remoteCardPaymentClient: RemoteTapToPayReaderClient = ServiceLocator.remoteCardReaderClient + private let celebration: PaymentCaptureCelebrationProtocol private var walletSuppressionRequestToken: PKSuppressionRequestToken? @@ -66,6 +69,7 @@ final class PaymentCaptureOrchestrator: PaymentCaptureOrchestrating { private var handlersForActivePayment: PaymentHandlers? = nil func collectPayment(for order: Order, + using reader: CardReader, orderTotal: NSDecimalNumber, paymentGatewayAccount: PaymentGatewayAccount, paymentMethodTypes: [String], @@ -90,16 +94,9 @@ final class PaymentCaptureOrchestrator: PaymentCaptureOrchestrating { paymentMethodTypes: paymentMethodTypes, stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier) - /// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the - /// reader begins to collect payment. - /// - suppressPassPresentation() - - let paymentAction = CardPresentPaymentAction.collectPayment( - siteID: order.siteID, - orderID: order.orderID, - parameters: parameters, - onCardReaderMessage: { event in + switch reader.readerType { + case .remoteTapToPay: + remoteCardPaymentClient.onCardReaderMessage = { event in switch event { case .waitingForInput(let inputMethods): onWaitingForInput(inputMethods) @@ -110,21 +107,73 @@ final class PaymentCaptureOrchestrator: PaymentCaptureOrchestrating { case .cardInserted, .cardRemoved, .lowBattery, .lowBatteryResolved, .disconnected: DDLogInfo("💳 Unhandled card reader event received: \(event)") } - }, - onProcessingCompletion: { intent in + } + remoteCardPaymentClient.onProcessingCompletion = { intent in onProcessingCompletion(intent) - }, - onCompletion: { [weak self] result in - self?.allowPassPresentation() - self?.completePaymentIntentCapture( - order: order, - captureResult: result, - onCompletion: onCompletion - ) } - ) + remoteCardPaymentClient.onPaymentCompletion = { [weak self] result in + DDLogInfo("[Client] Payment capture orchestrator onPaymentCompletion called") + switch result { + case .success(let intent): + let action = CardPresentPaymentAction.captureOrderPaymentOnSite( + siteID: order.siteID, + orderID: order.orderID, + paymentIntent: intent) { result in + let captureResult = result.map { _ in + return intent + } + self?.completePaymentIntentCapture( + order: order, + captureResult: captureResult, + onCompletion: onCompletion + ) + } + DispatchQueue.main.async { + self?.stores.dispatch(action) + } + case .failure(let error): + DDLogError("Remote card reader payment capture error: \(error)") + } - stores.dispatch(paymentAction) + } + remoteCardPaymentClient.collectPayment(amount: orderTotal.decimalValue, orderID: order.orderID) + default: + /// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the + /// reader begins to collect payment. + /// + suppressPassPresentation() + + let paymentAction = CardPresentPaymentAction.collectPayment( + siteID: order.siteID, + orderID: order.orderID, + parameters: parameters, + onCardReaderMessage: { event in + switch event { + case .waitingForInput(let inputMethods): + onWaitingForInput(inputMethods) + case .displayMessage(let message): + onDisplayMessage(message) + case .cardDetailsCollected, .cardRemovedAfterClientSidePaymentCapture: + onProcessingMessage() + case .cardInserted, .cardRemoved, .lowBattery, .lowBatteryResolved, .disconnected: + DDLogInfo("💳 Unhandled card reader event received: \(event)") + } + }, + onProcessingCompletion: { intent in + onProcessingCompletion(intent) + }, + onCompletion: { [weak self] result in + self?.allowPassPresentation() + self?.completePaymentIntentCapture( + order: order, + captureResult: result, + onCompletion: onCompletion + ) + } + ) + + stores.dispatch(paymentAction) + } } func retryPayment(for order: Order, diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift index 821509b3b45..c88e5467075 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift @@ -9,6 +9,7 @@ import Yosemite /// protocol BuiltInCardReaderConnectionControlling { + var connectedReaders: CurrentValueSubject { get } func searchAndConnect(onCompletion: @escaping (Result) -> Void) } @@ -16,6 +17,8 @@ final class BuiltInCardReaderConnectionController: BuiltInCardReaderConnectionControlling where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { + var connectedReaders = CurrentValueSubject(nil) + private enum ControllerState { /// Initial state of the controller /// diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentPreflightController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentPreflightController.swift index 00ac8ba6703..fa0b4191e56 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentPreflightController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentPreflightController.swift @@ -65,7 +65,7 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, /// Controller to connect a card reader. /// - private var builtInConnectionController: BuiltInCardReaderConnectionController + private var builtInConnectionController: BuiltInCardReaderConnectionControlling private var tapToPayAlertProvider: TapToPayAlertProvider @@ -88,7 +88,7 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, onboardingPresenter: CardPresentPaymentsOnboardingPresenting, tapToPayAlertProvider: TapToPayAlertProvider, externalReaderConnectionController: CardReaderConnectionController, - tapToPayConnectionController: BuiltInCardReaderConnectionController, + tapToPayConnectionController: BuiltInCardReaderConnectionControlling, tapToPayReconnectionController: TapToPayReconnectionController, analyticsTracker: CardReaderConnectionAnalyticsTracker, stores: StoresManager = ServiceLocator.stores, @@ -113,7 +113,11 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, @MainActor func start(discoveryMethod: CardReaderDiscoveryMethod?) async { self.discoveryMethod = discoveryMethod - observeConnectedReaders() + if discoveryMethod == .remoteMobile { + observeRemotelyConnectedReaders() + } else { + observeConnectedReaders() + } await checkForConnectedReader() } @@ -190,7 +194,7 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, connectionController.searchAndConnect(onCompletion: { [weak self] result in self?.handleConnectionResult(result, paymentGatewayAccount: paymentGatewayAccount) }) - case (.localMobile, true): + case (.localMobile, true), (.remoteMobile, _): builtInConnectionController.searchAndConnect(onCompletion: { [weak self] result in self?.handleConnectionResult(result, paymentGatewayAccount: paymentGatewayAccount) }) @@ -203,7 +207,7 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, tapToPayReconnectionController.showAlertsForReconnection(from: alertsPresenter) { [weak self] result in guard let self = self else { return } switch self.discoveryMethod { - case .bluetoothScan: + case .bluetoothScan, .remoteMobile: Task { [weak self] in try await self?.automaticallyDisconnectFromReader() await self?.startReaderConnection(using: paymentGatewayAccount) @@ -285,6 +289,14 @@ where TapToPayAlertProvider.AlertDetails == AlertPresenter.AlertDetails, } stores.dispatch(action) } + + + var cancellables = Set() + private func observeRemotelyConnectedReaders() { + builtInConnectionController.connectedReaders.sink(receiveValue: { [weak self] reader in + self?.connectedReader = reader + }).store(in: &cancellables) + } } enum CardPresentPaymentPreflightError: Error, Equatable { diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteBuiltInCardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteBuiltInCardReaderConnectionController.swift new file mode 100644 index 00000000000..bc1ae01f4b7 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteBuiltInCardReaderConnectionController.swift @@ -0,0 +1,491 @@ +import Foundation +import Combine + +final class RemoteBuiltInCardReaderConnectionController: + BuiltInCardReaderConnectionControlling +where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { + private var readerClient = ServiceLocator.remoteCardReaderClient + private var cancellables = Set() + private let configProvider = ServiceLocator.cardReaderConfigProvider + private let alertProvider: AlertProvider + private let alertPresenter: AlertPresenter + private let configuration: CardPresentPaymentsConfiguration + private let stores: StoresManager + private let siteID: Int64 + var connectedReaders = CurrentValueSubject(nil) + + init(forSiteID siteID: Int64, + stores: StoresManager = ServiceLocator.stores, + alertsPresenter: AlertPresenter, + alertsProvider: AlertProvider, + configuration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration) { + self.siteID = siteID + self.stores = stores + self.alertProvider = alertsProvider + self.alertPresenter = alertsPresenter + self.configuration = configuration + } + + func searchAndConnect(onCompletion: @escaping (Result) -> Void) { + readerClient.start() + readerClient.onConnection = { [weak self] reader in + onCompletion(.success(.connected(reader))) + self?.connectedReaders.send(reader) + } + Task { [weak self] in + await self?.connect(onCompletion: onCompletion) + } + } + + func connect(onCompletion: @escaping (Result) -> Void) async { + do { + let locationToken = try await fetchLocationToken() + let connectionToken = try await fetchConnectionToken() + readerClient.connectCardReader(locationToken: locationToken, connectionToken: connectionToken) + } catch { + DDLogError("Remote reader connection error: \(error)") + onCompletion(.failure(error)) + } + } + + // TODO: put these on ReaderLocationProvider, ReaderTokenProvider protocols + private func fetchLocationToken() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + configProvider.fetchDefaultLocationID { result in + continuation.resume(with: result) + } + } + } + + private func fetchConnectionToken() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + configProvider.fetchToken { result in + continuation.resume(with: result) + } + } + } +} + +// Usage example for iPhone (server) +class RemoteTapToPayReaderServer: ObservableObject { + let networkingStack = NetworkingStack() + + private var cancellables = Set() + private let cardReaderService = ServiceLocator.cardReaderService + + var locationToken: String? + var connectionToken: String? + + @Published var serverMessages: [String] = [] + + func start() { + serverMessages.append("Initialising Tap to Pay Server") + + networkingStack.onListenerStateUpdate = { [weak self] state in + switch state { + case .ready: + self?.serverMessages.append("Server ready") + case .failed(let error): + self?.serverMessages.append("Server failed: \(error)") + default: + break + } + } + + networkingStack.setupServer() + networkingStack.onMessageReceived = { [weak self] data in + if let message = try? JSONDecoder().decode(RemoteCardReaderClientMessage.self, from: data) { + self?.serverMessages.append("Remote reader message received: \(message)") + self?.handleClientMessage(message) + } + } + networkingStack.onConnectionStateChanged = { [weak self] isConnected in + if !isConnected { + self?.networkingStack.reconnect() + } + } + } + + var paymentCancellable: AnyCancellable? + + func handleClientMessage(_ message: RemoteCardReaderClientMessage) { + serverMessages.append("Received remote reader message: \(message)") + switch message { + case .connectReader(let locationToken, let connectionToken): + self.locationToken = locationToken + self.connectionToken = connectionToken + do { + try cardReaderService.start(self, discoveryMethod: .localMobile) + startListeningForTapToPay { [weak self] error in + if case CardReaderServiceError.discovery(UnderlyingError.alreadyConnectedToReader) = error { + self?.serverMessages.append("Remote reader already connected") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.cardReaderConnected) + } else { + self?.serverMessages.append("Remote reader discovery error: \(error)") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.cardReaderConnectionFailed(error: error.localizedDescription)) + } + } onFoundTapToPayReader: { [weak self] reader in + guard let self else { return } + let connectionPublisher = cardReaderService.connect( + reader, + options: CardReaderConnectionOptions( + builtInOptions: .init(termsOfServiceAcceptancePermitted: true))) + + connectionPublisher.sink(receiveCompletion: { [weak self] result in + self?.serverMessages.append("Remote reader connection result: \(result)") + }, receiveValue: { [weak self] reader in + self?.serverMessages.append("Remote reader connected: \(reader)") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.cardReaderConnected) + }).store(in: &cancellables) + } + + } catch { + serverMessages.append("Remote reader error: \(error)") + networkingStack.sendMessage(RemoteCardReaderServerMessage.cardReaderConnectionFailed(error: error.localizedDescription)) + } + + case .collectPayment(let amount, let currency, let orderID): + let metadata = PaymentIntent.initMetadata(orderID: orderID, paymentType: PaymentIntent.PaymentTypes.single) + + let parameters = PaymentParameters(amount: amount, + currency: currency, + stripeSmallestCurrencyUnitMultiplier: Decimal(100), + paymentMethodTypes: ["card_present"], + metadata: metadata) + + let readerEventsSubscription = cardReaderService.readerEvents.sink { [weak self] event in + self?.serverMessages.append("Remote reader event: \(event)") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.cardReaderMessage(event: event)) + } + + paymentCancellable = cardReaderService.capturePayment(parameters) + .sink { [weak self] completion in + readerEventsSubscription.cancel() + if case .failure(let error) = completion { + self?.serverMessages.append("Remote reader payment error: \(error)") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.paymentFailed(error: error.localizedDescription)) + } + } receiveValue: { [weak self] paymentIntent in + self?.serverMessages.append("Remote reader payment intent collected: \(paymentIntent.id)") + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.paymentIntentConfirmed(intent: paymentIntent, orderID: orderID)) + self?.networkingStack.sendMessage(RemoteCardReaderServerMessage.paymentMethodCollectionSuccessful(intent: paymentIntent, orderID: orderID)) + } + } + + func startListeningForTapToPay(onError: @escaping (Error) -> Void, onFoundTapToPayReader: @escaping (CardReader) -> Void) { + cardReaderService.discoveredReaders + .subscribe(Subscribers.Sink( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + onError(error) + } + }, + receiveValue: { readers in + let supportedReaders = readers.filter({ + $0.readerType == .appleBuiltIn + }) + guard let reader = supportedReaders.first else { + return + } + onFoundTapToPayReader(reader) + } + )) + } + } +} + +import Hardware +extension RemoteTapToPayReaderServer: CardReaderConfigProvider { + func fetchToken(completion: @escaping (Result) -> Void) { + guard let connectionToken else { + return completion(.failure(RemoteTapToPayServiceError.connectingReaderWithoutToken)) + } + completion(.success(connectionToken)) + } + + func fetchDefaultLocationID(completion: @escaping (Result) -> Void) { + guard let locationToken else { + return completion(.failure(RemoteTapToPayServiceError.connectingReaderWithoutLocation)) + } + completion(.success(locationToken)) + } +} + + + +// Usage example for iPad (client) +class RemoteTapToPayReaderClient { + let networkingStack = NetworkingStack() + private let cardReaderService = ServiceLocator.cardReaderService + + var onConnection: ((CardReader) -> Void)? + var onCardReaderMessage: ((CardReaderEvent) -> Void)? + var onProcessingCompletion: ((PaymentIntent) -> Void)? + var onPaymentCompletion: ((Result) -> Void)? + + func start() { + networkingStack.startBrowsing() + networkingStack.onEndpointsChanged = { [weak self] endpoints in + if let firstEndpoint = endpoints.first { + self?.networkingStack.connectToEndpoint(firstEndpoint) + } + } + networkingStack.onMessageReceived = { [weak self] data in + if let message = try? JSONDecoder().decode(RemoteCardReaderServerMessage.self, from: data) { + self?.handleServerMessage(message) + } + } + networkingStack.onConnectionStateChanged = { [weak self] isConnected in + if !isConnected { + self?.networkingStack.reconnect() + } + } + } + + func connectCardReader(locationToken: String, connectionToken: String) { + networkingStack.sendMessage(RemoteCardReaderClientMessage.connectReader(locationToken: locationToken, + connectionToken: connectionToken)) + } + + func collectPayment(amount: Decimal, orderID: Int64) { + networkingStack.sendMessage(RemoteCardReaderClientMessage.collectPayment(amount: amount, currency: "usd", orderID: orderID)) + } + + func handleServerMessage(_ message: RemoteCardReaderServerMessage) { + switch message { + case .cardReaderConnected: + DDLogInfo("[Client] Remote card reader connected") + onConnection?( + CardReader( + serial: "RemoteReaderSerial", + vendorIdentifier: "WooRemote", + name: "Remote Tap to Pay", + status: .init(connected: true, remembered: false), + softwareVersion: nil, + batteryLevel: nil, + readerType: .remoteTapToPay, + locationId: "")) + case .cardReaderConnectionFailed(let error): + DDLogInfo("[Client] Remote card reader connection failed: \(error)") + case .cardReaderMessage(let event): + DDLogInfo("Card reader message: \(event)") + onCardReaderMessage?(event) + case .paymentIntentConfirmed(let intent, let orderID): + DDLogInfo("[Client] Remote card reader payment intent confirmed. Intent: \(intent), order ID: \(orderID)") + onProcessingCompletion?(intent) + case .paymentMethodCollectionSuccessful(let intent, let orderID): + DDLogInfo("[Client] Remote card reader payment success. Intent ID: \(intent.id), order ID: \(orderID)") + onPaymentCompletion?(.success(intent)) + case .paymentFailed(let error): + DDLogInfo("[Client] Remote payment failed: \(error)") + onPaymentCompletion?(.failure(RemoteTapToPayServiceError.paymentFailed(details: error))) + } + } +} + +import Yosemite + +enum RemoteTapToPayServiceError: Error { + case connectingReaderWithoutToken + case connectingReaderWithoutLocation + case paymentFailed(details: String) +} + +import Network + +// Shared message types +enum RemoteCardReaderClientMessage: Codable { + case connectReader(locationToken: String, connectionToken: String) + case collectPayment(amount: Decimal, currency: String, orderID: Int64) +} + +enum RemoteCardReaderServerMessage: Codable { + case cardReaderConnected + case cardReaderConnectionFailed(error: String) + case paymentFailed(error: String) + case paymentIntentConfirmed(intent: PaymentIntent, orderID: Int64) + case paymentMethodCollectionSuccessful(intent: PaymentIntent, orderID: Int64) + case cardReaderMessage(event: CardReaderEvent) +} + + +// License for networking code. I've changed it some. +/** + Copyright © 2024 Apple Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +class NetworkingStack { + private var connection: NWConnection? + private var browser: NWBrowser? + private let queue = DispatchQueue(label: "NetworkingQueue") + + var onMessageReceived: ((Data) -> Void)? + var onConnectionStateChanged: ((Bool) -> Void)? + var onEndpointsChanged: (([NWEndpoint]) -> Void)? + + var onListenerStateUpdate: ((NWListener.State) -> Void)? + + func setupServer(passcode: String = "1234") { + let parameters = NWParameters.init(passcode: passcode) + + let listener = try! NWListener(using: parameters) + listener.service = NWListener.Service(name: "CardReader", type: "_card-reader._tcp") + + listener.stateUpdateHandler = { [weak self] state in + self?.onListenerStateUpdate?(state) + } + + listener.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection) + } + + listener.start(queue: .main) + } + + func startBrowsing() { + let parameters = NWParameters() + parameters.includePeerToPeer = true + + browser = NWBrowser(for: .bonjour(type: "_card-reader._tcp", domain: nil), using: parameters) + + browser?.stateUpdateHandler = { state in + switch state { + case .ready: + print("Browser ready") + case .failed(let error): + print("Browser failed: \(error)") + default: + break + } + } + + browser?.browseResultsChangedHandler = { [weak self] results, changes in + let endpoints = results.map { $0.endpoint } + self?.onEndpointsChanged?(endpoints) + } + + browser?.start(queue: queue) + } + + func connectToEndpoint(_ endpoint: NWEndpoint) { + let connection = NWConnection(to: endpoint, using: NWParameters(passcode: "1234")) + handleConnection(connection) + } + + private func handleConnection(_ connection: NWConnection) { + self.connection = connection + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connection established") + self?.onConnectionStateChanged?(true) + self?.receiveMessage() + case .failed(let error): + print("Connection failed: \(error)") + self?.onConnectionStateChanged?(false) + case .waiting(let error): + print("Connection waiting: \(error)") + default: + break + } + } + + connection.start(queue: queue) + } + + func sendMessage(_ message: T) { + guard let data = try? JSONEncoder().encode(message) else { + print("Failed to encode message") + return + } + + connection?.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Failed to send message: \(error)") + } + }) + } + + private func receiveMessage() { + connection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in + if let data = content { + self?.onMessageReceived?(data) + } + + if let error = error { + print("Receive error: \(error)") + } else if isComplete { + print("Connection closed") + self?.onConnectionStateChanged?(false) + } else { + self?.receiveMessage() + } + } + } + + func reconnect() { + connection?.restart() + } +} + + +/// https://developer.apple.com/documentation/Network/building-a-custom-peer-to-peer-protocol +import CryptoKit + +extension NWParameters { + + // Create parameters for use in PeerConnection and PeerListener. + convenience init(passcode: String) { + // Customize TCP options to enable keepalives. + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 2 + + // Create parameters with custom TLS and TCP options. + self.init(tls: NWParameters.tlsOptions(passcode: passcode), tcp: tcpOptions) + + // Enable using a peer-to-peer link. + self.includePeerToPeer = true + } + + // Create TLS options using a passcode to derive a preshared key. + private static func tlsOptions(passcode: String) -> NWProtocolTLS.Options { + let tlsOptions = NWProtocolTLS.Options() + + let authenticationKey = SymmetricKey(data: passcode.data(using: .utf8)!) + let authenticationCode = HMAC.authenticationCode(for: "CardReader".data(using: .utf8)!, using: authenticationKey) + + let authenticationDispatchData = authenticationCode.withUnsafeBytes { + DispatchData(bytes: $0) + } + + sec_protocol_options_add_pre_shared_key(tlsOptions.securityProtocolOptions, + authenticationDispatchData as __DispatchData, + stringToDispatchData("CardReader")! as __DispatchData) + sec_protocol_options_append_tls_ciphersuite(tlsOptions.securityProtocolOptions, + tls_ciphersuite_t(rawValue: TLS_PSK_WITH_AES_128_GCM_SHA256)!) + sec_protocol_options_set_max_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv12) + return tlsOptions + } + + // Create a utility function to encode strings as preshared key data. + private static func stringToDispatchData(_ string: String) -> DispatchData? { + guard let stringData = string.data(using: .utf8) else { + return nil + } + let dispatchData = stringData.withUnsafeBytes { + DispatchData(bytes: $0) + } + return dispatchData + } +} diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteTapToPayServerView.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteTapToPayServerView.swift new file mode 100644 index 00000000000..55d9106e260 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/RemoteTapToPayServerView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct RemoteTapToPayServerView: View { + @ObservedObject var server: RemoteTapToPayReaderServer + + var body: some View { + List { + ForEach(server.serverMessages, id: \.self) { message in + Text(message) + } + } + } +} + +#Preview { + RemoteTapToPayServerView(server: RemoteTapToPayReaderServer()) +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift index 494684a3839..e7cd680578b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift @@ -25,7 +25,7 @@ extension CardReaderType { name: "Wisepad 3", urlString: "https://woocommerce.com/wp-content/uploads/2022/12/wp3_product_sheet.pdf" ) - case .other, .appleBuiltIn: + case .other, .appleBuiltIn, .remoteTapToPay: return nil } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index 69aca6c2afb..019469a4c78 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -144,9 +144,11 @@ where BuiltInAlertProvider.AlertDetails == AlertPresenter.AlertDetails, switch connectionResult { case .completed(let reader, let paymentGatewayAccount): let paymentAlertProvider = paymentAlertProvider(for: reader) - self.attemptPayment(alertProvider: paymentAlertProvider, - paymentGatewayAccount: paymentGatewayAccount, - onCompletion: { [weak self] result in + self.attemptPayment( + using: reader, + alertProvider: paymentAlertProvider, + paymentGatewayAccount: paymentGatewayAccount, + onCompletion: { [weak self] result in guard let self = self else { return } // Inform about the collect payment state switch result { @@ -278,7 +280,8 @@ private extension CollectOrderPaymentUseCase { /// Attempts to collect payment for an order. /// - func attemptPayment(alertProvider paymentAlerts: any CardReaderTransactionAlertsProviding, + func attemptPayment(using reader: CardReader, + alertProvider paymentAlerts: any CardReaderTransactionAlertsProviding, paymentGatewayAccount: PaymentGatewayAccount, onCompletion: @escaping (Result) -> ()) { checkOrderIsStillEligibleForPayment(alertProvider: paymentAlerts, onPaymentCompletion: onCompletion) { [weak self] result in @@ -288,6 +291,7 @@ private extension CollectOrderPaymentUseCase { return self.checkThenHandlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: reader, onCompletion: onCompletion) case .success: guard let orderTotal = self.orderTotal else { @@ -298,6 +302,7 @@ private extension CollectOrderPaymentUseCase { // Start collect payment process self.paymentOrchestrator.collectPayment( for: self.order, + using: reader, orderTotal: orderTotal, paymentGatewayAccount: paymentGatewayAccount, paymentMethodTypes: self.configuration.paymentMethods.map(\.rawValue), @@ -353,6 +358,7 @@ private extension CollectOrderPaymentUseCase { self?.checkThenHandlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: reader, onCompletion: onCompletion) } }) @@ -385,11 +391,13 @@ private extension CollectOrderPaymentUseCase { func checkThenHandlePaymentFailureAndRetryPayment(_ error: Error, alertProvider paymentAlerts: any CardReaderTransactionAlertsProviding, paymentGatewayAccount: PaymentGatewayAccount, + cardReader: CardReader, onCompletion: @escaping (Result) -> ()) { guard case ServerSidePaymentCaptureError.paymentGateway(.otherError) = error else { return handlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) } @@ -402,6 +410,7 @@ private extension CollectOrderPaymentUseCase { return handlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) } @@ -418,6 +427,7 @@ private extension CollectOrderPaymentUseCase { func handlePaymentFailureAndRetryPayment(_ error: Error, alertProvider paymentAlerts: any CardReaderTransactionAlertsProviding, paymentGatewayAccount: PaymentGatewayAccount, + cardReader: CardReader, onCompletion: @escaping (Result) -> ()) { DDLogError("Failed to collect payment: \(error.localizedDescription)") @@ -433,11 +443,13 @@ private extension CollectOrderPaymentUseCase { presentRetryByRestartingError(error: error, paymentAlerts: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) case .reuseIntent: presentRetryWithoutRestartingError(error: error, paymentAlerts: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) case .dontRetry: presentNonRetryableError(error: error, @@ -449,6 +461,7 @@ private extension CollectOrderPaymentUseCase { private func presentRetryByRestartingError(error: Error, paymentAlerts: any CardReaderTransactionAlertsProviding, paymentGatewayAccount: PaymentGatewayAccount, + cardReader: CardReader, onCompletion: @escaping (Result) -> ()) { alertsPresenter.present( viewModel: paymentAlerts.error(error: error, @@ -460,7 +473,8 @@ private extension CollectOrderPaymentUseCase { switch result { case .success, .failure(CardReaderServiceError.paymentCancellation(.noActivePaymentIntent)): // Retry payment - self.attemptPayment(alertProvider: paymentAlerts, + self.attemptPayment(using: cardReader, + alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, onCompletion: onCompletion) case .failure(let cancelError): @@ -480,6 +494,7 @@ private extension CollectOrderPaymentUseCase { private func presentRetryWithoutRestartingError(error: Error, paymentAlerts: any CardReaderTransactionAlertsProviding, paymentGatewayAccount: PaymentGatewayAccount, + cardReader: CardReader, onCompletion: @escaping (Result) -> ()) { alertsPresenter.present( viewModel: paymentAlerts.error( @@ -492,6 +507,7 @@ private extension CollectOrderPaymentUseCase { return self.checkThenHandlePaymentFailureAndRetryPayment(error, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) case .success: self.paymentOrchestrator.retryPayment(for: self.order) { [weak self] result in @@ -512,6 +528,7 @@ private extension CollectOrderPaymentUseCase { self.checkThenHandlePaymentFailureAndRetryPayment(retryError, alertProvider: paymentAlerts, paymentGatewayAccount: paymentGatewayAccount, + cardReader: cardReader, onCompletion: onCompletion) } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift index 1b0c5abe851..5e678473304 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift @@ -562,7 +562,7 @@ enum PaymentMethodsError: Error { private extension CardReaderDiscoveryMethod { var analyticsCardReaderType: WooAnalyticsEvent.PaymentsFlow.CardReaderType { switch self { - case .localMobile: + case .localMobile, .remoteMobile: return .builtIn case .bluetoothScan: return .external diff --git a/WooCommerce/Resources/Info.plist b/WooCommerce/Resources/Info.plist index ddd53c430d0..842320a4cbc 100644 --- a/WooCommerce/Resources/Info.plist +++ b/WooCommerce/Resources/Info.plist @@ -149,5 +149,10 @@ UIViewControllerBasedStatusBarAppearance + NSBonjourServices + + _card-reader._tcp + _card-reader._udp + diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index bfef929c3ba..f7633e371d3 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -842,6 +842,7 @@ 20BCF6EE2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BCF6ED2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift */; }; 20BCF6F02B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BCF6EF2B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift */; }; 20BCF6F72B0E5AF000954840 /* MockSystemStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BCF6F62B0E5AEF00954840 /* MockSystemStatusService.swift */; }; + 20C9634D2CA2B143005A6F7C /* RemoteBuiltInCardReaderConnectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C9634C2CA2B143005A6F7C /* RemoteBuiltInCardReaderConnectionController.swift */; }; 20CC1EDB2AFA8381006BD429 /* InPersonPaymentsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20CC1EDA2AFA8381006BD429 /* InPersonPaymentsMenu.swift */; }; 20CC1EDD2AFA99DF006BD429 /* InPersonPaymentsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20CC1EDC2AFA99DF006BD429 /* InPersonPaymentsMenuViewModel.swift */; }; 20CCBF212B0E15C0003102E6 /* WooPaymentsDepositsCurrencyOverviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20CCBF202B0E15C0003102E6 /* WooPaymentsDepositsCurrencyOverviewViewModelTests.swift */; }; @@ -858,6 +859,7 @@ 20D5CB532AFCF8E7009A39C3 /* PaymentsToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D5CB522AFCF8E7009A39C3 /* PaymentsToggleRow.swift */; }; 20DA6DDB2B681175002AA0FB /* AdaptiveModalContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DA6DDA2B681175002AA0FB /* AdaptiveModalContainer.swift */; }; 20E188842AD059A50053E945 /* AboutTapToPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E188832AD059A50053E945 /* AboutTapToPayView.swift */; }; + 20EF9BAC2CA6C690001047F2 /* RemoteTapToPayServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20EF9BAB2CA6C690001047F2 /* RemoteTapToPayServerView.swift */; }; 247CE89C2583402A00F9D9D1 /* Embassy in Frameworks */ = {isa = PBXBuildFile; productRef = 247CE89B2583402A00F9D9D1 /* Embassy */; }; 247CE8A6258340E600F9D9D1 /* ScreenshotImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 247CE8A5258340E600F9D9D1 /* ScreenshotImages.xcassets */; }; 24C5AC7625A53021008FD769 /* Embassy in Frameworks */ = {isa = PBXBuildFile; productRef = 247CE89B2583402A00F9D9D1 /* Embassy */; }; @@ -3931,6 +3933,7 @@ 20BCF6ED2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsOverviewViewModel.swift; sourceTree = ""; }; 20BCF6EF2B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsOverviewViewModelTests.swift; sourceTree = ""; }; 20BCF6F62B0E5AEF00954840 /* MockSystemStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSystemStatusService.swift; sourceTree = ""; }; + 20C9634C2CA2B143005A6F7C /* RemoteBuiltInCardReaderConnectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBuiltInCardReaderConnectionController.swift; sourceTree = ""; }; 20CC1EDA2AFA8381006BD429 /* InPersonPaymentsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsMenu.swift; sourceTree = ""; }; 20CC1EDC2AFA99DF006BD429 /* InPersonPaymentsMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsMenuViewModel.swift; sourceTree = ""; }; 20CCBF202B0E15C0003102E6 /* WooPaymentsDepositsCurrencyOverviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsCurrencyOverviewViewModelTests.swift; sourceTree = ""; }; @@ -3947,6 +3950,7 @@ 20D5CB522AFCF8E7009A39C3 /* PaymentsToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsToggleRow.swift; sourceTree = ""; }; 20DA6DDA2B681175002AA0FB /* AdaptiveModalContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveModalContainer.swift; sourceTree = ""; }; 20E188832AD059A50053E945 /* AboutTapToPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutTapToPayView.swift; sourceTree = ""; }; + 20EF9BAB2CA6C690001047F2 /* RemoteTapToPayServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTapToPayServerView.swift; sourceTree = ""; }; 247CE8A5258340E600F9D9D1 /* ScreenshotImages.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ScreenshotImages.xcassets; sourceTree = ""; }; 24C579D124F476300076E1B4 /* Woo-Alpha.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Woo-Alpha.entitlements"; sourceTree = ""; }; 24F98C4F2502AEE200F49B68 /* EventLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLogging.swift; sourceTree = ""; }; @@ -12292,6 +12296,8 @@ 037D270C28CA444F00A3F924 /* CardReaderModalFlowViewControllerProtocol.swift */, D8815ADE26383EE700EDAD62 /* CardPresentPaymentsModalViewController.xib */, 03E471BD29388787001A58AD /* BuiltInCardReaderConnectionController.swift */, + 20C9634C2CA2B143005A6F7C /* RemoteBuiltInCardReaderConnectionController.swift */, + 20EF9BAB2CA6C690001047F2 /* RemoteTapToPayServerView.swift */, 03BB9EA4292E2D0C00251E9E /* CardReaderConnectionController.swift */, 035DBA46292D0994003E5125 /* CardPresentPaymentPreflightController.swift */, D8EE9690264D328A0033B2F9 /* LegacyReceiptViewController.swift */, @@ -15052,6 +15058,7 @@ 45E9A6E724DAE23300A600E8 /* ProductReviewsViewModel.swift in Sources */, DEC51A9D274F8528009F3DF4 /* JCPJetpackInstallStepsViewModel.swift in Sources */, 455DC3A327393C7E00D4644C /* OrderDatesFilterViewController.swift in Sources */, + 20EF9BAC2CA6C690001047F2 /* RemoteTapToPayServerView.swift in Sources */, 45B6F4EF27592A4000C18782 /* ReviewsView.swift in Sources */, 86A4EBBD2B2F1306008011F5 /* ThemesPreviewViewModel.swift in Sources */, B6F37970293798ED00718561 /* AnalyticsHubTodayRangeData.swift in Sources */, @@ -16221,6 +16228,7 @@ 4520A15C2721B2A9001FA573 /* FilterOrderListViewModel.swift in Sources */, B582F95920FFCEAA0060934A /* UITableViewHeaderFooterView+Helpers.swift in Sources */, DA41043A2C247B6900E8456A /* POSOrderPreviewService.swift in Sources */, + 20C9634D2CA2B143005A6F7C /* RemoteBuiltInCardReaderConnectionController.swift in Sources */, B933CCB02AA6220E00938F3F /* TaxRateRow.swift in Sources */, 57532CAC24BFF4DA0032B84E /* MessageComposerPresenter.swift in Sources */, DE4D23AE29B1B0EF003A4B5D /* WPCom2FALoginViewModel.swift in Sources */, diff --git a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift index 58718fd2e15..437978f6f56 100644 --- a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift +++ b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift @@ -63,6 +63,11 @@ public enum CardPresentPaymentAction: Action { onProcessingCompletion: (PaymentIntent) -> Void, onCompletion: (Result) -> Void) + case collectPaymentMethod(parameters: PaymentParameters, + onCardReaderMessage: (CardReaderEvent) -> Void, + onProcessingCompletion: (String) -> Void, + onCompletion: (Result) -> Void) + /// Cancels an active attempt to collect a payment. case cancelPayment(onCompletion: ((Result) -> Void)?) @@ -98,4 +103,9 @@ public enum CardPresentPaymentAction: Action { /// Fetches Charge details by charge ID /// case fetchWCPayCharge(siteID: Int64, chargeID: String, onCompletion: (Result) -> Void) + + case captureOrderPaymentOnSite(siteID: Int64, + orderID: Int64, + paymentIntent: PaymentIntent, + onCompletion: (Result) -> Void) } diff --git a/Yosemite/Yosemite/Model/Storage/CardReaderType+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/CardReaderType+ReadOnlyConvertible.swift index a5a6dc27235..7158b5ebc2a 100644 --- a/Yosemite/Yosemite/Model/Storage/CardReaderType+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/CardReaderType+ReadOnlyConvertible.swift @@ -18,6 +18,8 @@ extension StorageCardReaderType: ReadOnlyConvertible { self = .wisepad3 case .appleBuiltIn: self = .appleBuiltIn + case .remoteTapToPay: + self = .remoteTapToPay case .other: self = .other } @@ -35,6 +37,8 @@ extension StorageCardReaderType: ReadOnlyConvertible { return .wisepad3 case .appleBuiltIn: return .appleBuiltIn + case .remoteTapToPay: + return .remoteTapToPay case .other: return .other } diff --git a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift index cb82ab73c2a..c4dd6a971dd 100644 --- a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift @@ -115,6 +115,11 @@ public final class CardPresentPaymentStore: Store { onCardReaderMessage: event, onProcessingCompletion: processPaymentCompletion, onCompletion: completion) + case .collectPaymentMethod(let parameters, let event, let processPaymentCompletion, let completion): + collectPaymentMethod(parameters: parameters, + onCardReaderMessage: event, + onProcessingCompletion: processPaymentCompletion, + onCompletion: completion) case .retryPayment(let siteID, let orderID, let event, let processPaymentCompletion, let completion): retryActivePayment(siteID: siteID, orderID: orderID, @@ -137,6 +142,13 @@ public final class CardPresentPaymentStore: Store { publishCardReaderConnections(onCompletion: completion) case .fetchWCPayCharge(let siteID, let chargeID, let completion): fetchCharge(siteID: siteID, chargeID: chargeID, completion: completion) + case .captureOrderPaymentOnSite(let siteID, let orderID, let paymentIntent, let completion): + captureOrderPaymentOnSite(siteID: siteID, orderID: orderID, paymentIntent: paymentIntent) + .print("Capture payment: \(paymentIntent)") + .sink { result in + completion(result) + } + .store(in: &cancellables) } } } @@ -319,6 +331,26 @@ private extension CardPresentPaymentStore { } } + func collectPaymentMethod(parameters: PaymentParameters, + onCardReaderMessage: @escaping (CardReaderEvent) -> Void, + onProcessingCompletion: @escaping (String) -> Void, + onCompletion: @escaping (Result) -> Void) { + // Observe status events fired by the card reader + let readerEventsSubscription = cardReaderService.readerEvents.sink { event in + onCardReaderMessage(event) + } + + paymentCancellable = cardReaderService.capturePayment(parameters) + .sink { completion in + readerEventsSubscription.cancel() + if case .failure(let error) = completion { + onCompletion(.failure(error)) + } + } receiveValue: { paymentIntent in + onProcessingCompletion(paymentIntent.id) + } + } + func retryActivePayment(siteID: Int64, orderID: Int64, onCardReaderMessage: @escaping (CardReaderEvent) -> Void,