Skip to content

Commit 80503f0

Browse files
authored
Merge pull request #6602 from woocommerce/feat/5983-refund-interac
IPP: support card present refunds for Interac
2 parents 5cd1068 + b2446b3 commit 80503f0

File tree

9 files changed

+600
-64
lines changed

9 files changed

+600
-64
lines changed

Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ extension StripeCardReaderService {
563563
}
564564
promise(.success(()))
565565
})
566-
//TODO: handle timeout?
566+
//TODO: 5983 - handle timeout when called from retry after refund failure
567567
}.eraseToAnyPublisher()
568568
}
569569
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Yosemite
2+
import PassKit
3+
4+
/// Orchestrates the sequence of actions required to refund in-person that is required for certain payment methods:
5+
/// 1. Check if there is a card reader connected
6+
/// 2. Launch the reader discovering and pairing UI if there is no reader connected
7+
/// 3. Refund payment in-person with a card reader.
8+
/// Steps 1 and 2 are the same as the payments flow in `PaymentCaptureOrchestrator`.
9+
final class CardPresentRefundOrchestrator {
10+
private let stores: StoresManager
11+
private var walletSuppressionRequestToken: PKSuppressionRequestToken?
12+
13+
init(stores: StoresManager) {
14+
self.stores = stores
15+
}
16+
17+
/// Refunds a payment for an order in-person, which is required for certain payment methods like Interac in Canada.
18+
/// - Parameters:
19+
/// - amount: the amount to refund in Decimal.
20+
/// - charge: details about how the order was charged to verify refund.
21+
/// - paymentGatewayAccount: payment gateway (e.g. WCPay or Stripe extension).
22+
/// - onWaitingForInput: called when the card reader is waiting for card input.
23+
/// - onProcessingMessage: called when the refund is processing.
24+
/// - onDisplayMessage: called when the card reader sends a message to display to the user.
25+
/// - onCompletion: called when the refund completes.
26+
func refund(amount: Decimal,
27+
charge: WCPayCharge,
28+
paymentGatewayAccount: PaymentGatewayAccount,
29+
onWaitingForInput: @escaping () -> Void,
30+
onProcessingMessage: @escaping () -> Void,
31+
onDisplayMessage: @escaping (String) -> Void,
32+
onCompletion: @escaping (Result<Void, Error>) -> Void) {
33+
/// Sets the state of `CardPresentPaymentStore`.
34+
let setAccount = CardPresentPaymentAction.use(paymentGatewayAccount: paymentGatewayAccount)
35+
stores.dispatch(setAccount)
36+
37+
/// Briefly suppresses pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the
38+
/// reader begins to collect payment.
39+
suppressPassPresentation()
40+
41+
/// Refunds payment in-person with a card reader.
42+
let refundParameters = RefundParameters(chargeId: charge.id, amount: amount, currency: charge.currency)
43+
let refundAction = CardPresentPaymentAction.refundPayment(parameters: refundParameters,
44+
onCardReaderMessage: { event in
45+
switch event {
46+
case .waitingForInput:
47+
onWaitingForInput()
48+
case .displayMessage(let message):
49+
onDisplayMessage(message)
50+
default:
51+
break
52+
}
53+
}, onCompletion: { result in
54+
onCompletion(result)
55+
})
56+
stores.dispatch(refundAction)
57+
}
58+
59+
/// Cancels the current refund.
60+
/// - Parameter onCompletion: called when the cancellation completes.
61+
func cancelRefund(onCompletion: @escaping (Result<Void, Error>) -> Void) {
62+
let action = CardPresentPaymentAction.cancelRefund { [weak self] result in
63+
self?.allowPassPresentation()
64+
onCompletion(result)
65+
}
66+
stores.dispatch(action)
67+
}
68+
}
69+
70+
// MARK: - Apple wallet suppression
71+
72+
private extension CardPresentRefundOrchestrator {
73+
/// Suppresses wallet presentation. This requires a special entitlement from Apple:
74+
/// `com.apple.developer.passkit.pass-presentation-suppression`
75+
/// See Woo-*.entitlements in WooCommerce/Resources
76+
func suppressPassPresentation() {
77+
/// iPads don't support NFC passes. Attempting to call `requestAutomaticPassPresentationSuppression` on them will
78+
/// return 0 `notSupported`
79+
///
80+
guard !UIDevice.isPad() else {
81+
return
82+
}
83+
84+
guard !PKPassLibrary.isSuppressingAutomaticPassPresentation() else {
85+
return
86+
}
87+
88+
walletSuppressionRequestToken = PKPassLibrary.requestAutomaticPassPresentationSuppression() { result in
89+
guard result == .success else {
90+
DDLogWarn("Automatic pass presentation suppression request failed. Reason: \(result.rawValue)")
91+
92+
let logProperties: [String: Any] = ["PKAutomaticPassPresentationSuppressionResult": result.rawValue]
93+
ServiceLocator.crashLogging.logMessage(
94+
"Automatic pass presentation suppression request failed",
95+
properties: logProperties,
96+
level: .warning
97+
)
98+
return
99+
}
100+
}
101+
}
102+
103+
/// Restores wallet presentation.
104+
func allowPassPresentation() {
105+
/// iPads don't have passes (wallets) to present
106+
///
107+
guard !UIDevice.isPad() else {
108+
return
109+
}
110+
111+
guard let walletSuppressionRequestToken = walletSuppressionRequestToken, walletSuppressionRequestToken != 0 else {
112+
return
113+
}
114+
115+
PKPassLibrary.endAutomaticPassPresentationSuppression(withRequestToken: walletSuppressionRequestToken)
116+
}
117+
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Issue Refunds/RefundConfirmationViewController.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,15 @@ extension RefundConfirmationViewController {
6969
/// Submits the refund and dismisses the flow upon successful completion.
7070
///
7171
func submitRefund() {
72-
onRefundCreationAction?()
73-
viewModel.submit { [weak self] result in
72+
viewModel.submit(rootViewController: self,
73+
showInProgressUI: { [weak self] in
74+
self?.onRefundCreationAction?()
75+
}, onCompletion: { [weak self] result in
7476
if let error = result.failure {
7577
self?.displayNotice(with: error)
7678
}
7779
self?.onRefundCompletion?(result.failure)
78-
}
80+
})
7981
}
8082
}
8183

WooCommerce/Classes/ViewRelated/Orders/Order Details/Issue Refunds/RefundConfirmationViewModel.swift

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ final class RefundConfirmationViewModel {
4848
)
4949
]
5050

51+
/// Retains the use-case so it can perform all of its async tasks.
52+
///
53+
private var submissionUseCase: RefundSubmissionProtocol?
54+
5155
private let analytics: Analytics
5256

5357
init(details: Details,
@@ -62,37 +66,62 @@ final class RefundConfirmationViewModel {
6266

6367
/// Submit the refund.
6468
///
65-
func submit(onCompletion: @escaping (Result<Void, Error>) -> Void) {
66-
// Create refund object
69+
/// - Parameters:
70+
/// - rootViewController: view controller used to present in-person refund alerts if needed.
71+
/// - showInProgressUI: called when in-progress UI should be shown during refund submission. In-person refund submission does not show in-progress UI.
72+
/// - onCompletion: called when the refund submission completes.
73+
func submit(rootViewController: UIViewController,
74+
showInProgressUI: @escaping (() -> Void),
75+
onCompletion: @escaping (Result<Void, Error>) -> Void) {
76+
// TODO: 6601 - remove Interac workaround when the API support is shipped.
77+
let isInterac: Bool = {
78+
switch details.charge?.paymentMethodDetails {
79+
case .some(.interacPresent):
80+
return true
81+
default:
82+
return false
83+
}
84+
}()
85+
let automaticallyRefundsPayment = isInterac && ServiceLocator.featureFlagService.isFeatureFlagEnabled(.canadaInPersonPayments) ?
86+
false: gatewaySupportsAutomaticRefunds()
87+
88+
// Creates refund object.
6789
let shippingLine = details.refundsShipping ? details.order.shippingLines.first : nil
6890
let fees = details.refundsFees ? details.order.fees : []
6991
let useCase = RefundCreationUseCase(amount: details.amount,
7092
reason: reasonForRefundCellViewModel.value,
71-
automaticallyRefundsPayment: gatewaySupportsAutomaticRefunds(),
93+
automaticallyRefundsPayment: automaticallyRefundsPayment,
7294
items: details.items,
7395
shippingLine: shippingLine,
7496
fees: fees,
7597
currencyFormatter: currencyFormatter)
7698
let refund = useCase.createRefund()
7799

78-
// Submit it
79-
let action = RefundAction.createRefund(siteID: details.order.siteID, orderID: details.order.orderID, refund: refund) { [weak self] _, error in
100+
// Submits refund.
101+
let submissionUseCase = RefundSubmissionUseCase(siteID: details.order.siteID,
102+
details: .init(order: details.order,
103+
charge: details.charge,
104+
amount: details.amount),
105+
rootViewController: rootViewController,
106+
currencyFormatter: currencyFormatter,
107+
stores: actionProcessor,
108+
analytics: analytics)
109+
self.submissionUseCase = submissionUseCase
110+
submissionUseCase.submitRefund(refund,
111+
showInProgressUI: showInProgressUI,
112+
onCompletion: { [weak self] result in
80113
guard let self = self else { return }
81-
if let error = error {
82-
DDLogError("Error creating refund: \(refund)\nWith Error: \(error)")
83-
self.trackCreateRefundRequestFailed(error: error)
84-
return onCompletion(.failure(error))
85-
}
86114

87-
// We don't care if the "update order" fails. We return .success() as the refund creation already succeeded.
88-
self.updateOrder { _ in
89-
onCompletion(.success(()))
115+
switch result {
116+
case .success:
117+
// We don't care if the "update order" fails. We return .success() as the refund creation already succeeded.
118+
self.updateOrder { _ in
119+
onCompletion(.success(()))
120+
}
121+
default:
122+
onCompletion(result)
90123
}
91-
self.trackCreateRefundRequestSuccess()
92-
}
93-
94-
actionProcessor.dispatch(action)
95-
trackCreateRefundRequest()
124+
})
96125
}
97126

98127
/// Updates the order associated with the refund to reflect the latest refund status.
@@ -192,28 +221,6 @@ extension RefundConfirmationViewModel {
192221
func trackSummaryButtonTapped() {
193222
analytics.track(event: WooAnalyticsEvent.IssueRefund.summaryButtonTapped(orderID: details.order.orderID))
194223
}
195-
196-
/// Tracks when the create refund request is made.
197-
///
198-
private func trackCreateRefundRequest() {
199-
analytics.track(event: WooAnalyticsEvent.IssueRefund.createRefund(orderID: details.order.orderID,
200-
fullyRefunded: details.amount == details.order.total,
201-
method: .items,
202-
gateway: details.order.paymentMethodID,
203-
amount: details.amount))
204-
}
205-
206-
/// Tracks when the create refund request succeeds.
207-
///
208-
private func trackCreateRefundRequestSuccess() {
209-
analytics.track(event: WooAnalyticsEvent.IssueRefund.createRefundSuccess(orderID: details.order.orderID))
210-
}
211-
212-
/// Tracks when the create refund request fails.
213-
///
214-
private func trackCreateRefundRequestFailed(error: Error) {
215-
analytics.track(event: WooAnalyticsEvent.IssueRefund.createRefundFailed(orderID: details.order.orderID, error: error))
216-
}
217224
}
218225

219226
// MARK: - Section and Row Types

0 commit comments

Comments
 (0)