Skip to content

Commit cc9ae29

Browse files
authored
Merge pull request #8328 from woocommerce/issue/8321-duplicate-payment-capture-orchestrator-for-refactor
[Mobile Payments] Duplicate PaymentCaptureOrchestrator for refactor
2 parents 7ebad38 + 361f4cc commit cc9ae29

File tree

5 files changed

+282
-13
lines changed

5 files changed

+282
-13
lines changed
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import Yosemite
2+
import PassKit
3+
import WooFoundation
4+
5+
/// Orchestrates the sequence of actions required to capture a payment:
6+
/// 1. Check if there is a card reader connected
7+
/// 2. Launch the reader discovering and pairing UI if there is no reader connected
8+
/// 3. Obtain a Payment Intent from the card reader (i.e., create a payment intent, collect a payment method, and process the payment)
9+
/// 4. Submit the Payment Intent to WCPay to capture a payment
10+
/// Steps 1 and 2 will be implemented as part of https://github.com/woocommerce/woocommerce-ios/issues/4062
11+
final class LegacyPaymentCaptureOrchestrator {
12+
private let currencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)
13+
private let personNameComponentsFormatter = PersonNameComponentsFormatter()
14+
private let paymentReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer
15+
16+
private let celebration = PaymentCaptureCelebration()
17+
18+
private var walletSuppressionRequestToken: PKSuppressionRequestToken?
19+
20+
private let stores: StoresManager
21+
22+
init(stores: StoresManager = ServiceLocator.stores,
23+
paymentReceiptEmailParameterDeterminer: ReceiptEmailParameterDeterminer = PaymentReceiptEmailParameterDeterminer()) {
24+
self.stores = stores
25+
self.paymentReceiptEmailParameterDeterminer = paymentReceiptEmailParameterDeterminer
26+
}
27+
28+
func collectPayment(for order: Order,
29+
orderTotal: NSDecimalNumber,
30+
paymentGatewayAccount: PaymentGatewayAccount,
31+
paymentMethodTypes: [String],
32+
stripeSmallestCurrencyUnitMultiplier: Decimal,
33+
onWaitingForInput: @escaping (CardReaderInput) -> Void,
34+
onProcessingMessage: @escaping () -> Void,
35+
onDisplayMessage: @escaping (String) -> Void,
36+
onProcessingCompletion: @escaping (PaymentIntent) -> Void,
37+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> Void) {
38+
/// Set state of CardPresentPaymentStore
39+
///
40+
let setAccount = CardPresentPaymentAction.use(paymentGatewayAccount: paymentGatewayAccount)
41+
42+
stores.dispatch(setAccount)
43+
44+
let parameters = paymentParameters(order: order,
45+
orderTotal: orderTotal,
46+
country: paymentGatewayAccount.country,
47+
statementDescriptor: paymentGatewayAccount.statementDescriptor,
48+
paymentMethodTypes: paymentMethodTypes,
49+
stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier)
50+
51+
/// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the
52+
/// reader begins to collect payment.
53+
///
54+
suppressPassPresentation()
55+
56+
let paymentAction = CardPresentPaymentAction.collectPayment(
57+
siteID: order.siteID,
58+
orderID: order.orderID,
59+
parameters: parameters,
60+
onCardReaderMessage: { event in
61+
switch event {
62+
case .waitingForInput(let inputMethods):
63+
onWaitingForInput(inputMethods)
64+
case .displayMessage(let message):
65+
onDisplayMessage(message)
66+
case .cardRemovedAfterClientSidePaymentCapture:
67+
onProcessingMessage()
68+
default:
69+
break
70+
}
71+
},
72+
onProcessingCompletion: { intent in
73+
onProcessingCompletion(intent)
74+
},
75+
onCompletion: { [weak self] result in
76+
self?.allowPassPresentation()
77+
self?.completePaymentIntentCapture(
78+
order: order,
79+
captureResult: result,
80+
onCompletion: onCompletion
81+
)
82+
}
83+
)
84+
85+
stores.dispatch(paymentAction)
86+
}
87+
88+
func cancelPayment(onCompletion: @escaping (Result<Void, Error>) -> Void) {
89+
let action = CardPresentPaymentAction.cancelPayment() { [weak self] result in
90+
self?.allowPassPresentation()
91+
onCompletion(result)
92+
}
93+
stores.dispatch(action)
94+
}
95+
96+
func emailReceipt(for order: Order, params: CardPresentReceiptParameters, onContent: @escaping (String) -> Void) {
97+
let action = ReceiptAction.generateContent(order: order, parameters: params) { emailContent in
98+
onContent(emailContent)
99+
}
100+
101+
stores.dispatch(action)
102+
}
103+
104+
func saveReceipt(for order: Order, params: CardPresentReceiptParameters) {
105+
let action = ReceiptAction.saveReceipt(order: order, parameters: params)
106+
107+
stores.dispatch(action)
108+
}
109+
}
110+
111+
private extension LegacyPaymentCaptureOrchestrator {
112+
/// Suppress wallet presentation. This requires a special entitlement from Apple:
113+
/// `com.apple.developer.passkit.pass-presentation-suppression`
114+
/// See Woo-*.entitlements in WooCommerce/Resources
115+
///
116+
func suppressPassPresentation() {
117+
/// iPads don't support NFC passes. Attempting to call `requestAutomaticPassPresentationSuppression` on them will
118+
/// return 0 `notSupported`
119+
///
120+
guard !UIDevice.isPad() else {
121+
return
122+
}
123+
124+
guard !PKPassLibrary.isSuppressingAutomaticPassPresentation() else {
125+
return
126+
}
127+
128+
walletSuppressionRequestToken = PKPassLibrary.requestAutomaticPassPresentationSuppression() { result in
129+
guard result == .success else {
130+
DDLogWarn("Automatic pass presentation suppression request failed. Reason: \(result.rawValue)")
131+
132+
let logProperties: [String: Any] = ["PKAutomaticPassPresentationSuppressionResult": result.rawValue]
133+
ServiceLocator.crashLogging.logMessage(
134+
"Automatic pass presentation suppression request failed",
135+
properties: logProperties,
136+
level: .warning
137+
)
138+
return
139+
}
140+
}
141+
}
142+
143+
/// Restore wallet presentation.
144+
func allowPassPresentation() {
145+
/// iPads don't have passes (wallets) to present
146+
///
147+
guard !UIDevice.isPad() else {
148+
return
149+
}
150+
151+
guard let walletSuppressionRequestToken = walletSuppressionRequestToken, walletSuppressionRequestToken != 0 else {
152+
return
153+
}
154+
155+
PKPassLibrary.endAutomaticPassPresentationSuppression(withRequestToken: walletSuppressionRequestToken)
156+
}
157+
}
158+
159+
private extension LegacyPaymentCaptureOrchestrator {
160+
func completePaymentIntentCapture(order: Order,
161+
captureResult: Result<PaymentIntent, Error>,
162+
onCompletion: @escaping (Result<CardPresentCapturedPaymentData, Error>) -> Void) {
163+
switch captureResult {
164+
case .failure(let error):
165+
onCompletion(.failure(error))
166+
case .success(let paymentIntent):
167+
guard let paymentMethod = paymentIntent.paymentMethod(),
168+
let receiptParameters = paymentIntent.receiptParameters() else {
169+
let error = CardReaderServiceError.paymentCapture()
170+
171+
DDLogError("⛔️ Payment completed without required metadata: \(error)")
172+
173+
onCompletion(.failure(error))
174+
return
175+
}
176+
177+
celebrate() // plays a sound, haptic
178+
saveReceipt(for: order, params: receiptParameters)
179+
onCompletion(.success(.init(paymentMethod: paymentMethod,
180+
receiptParameters: receiptParameters)))
181+
}
182+
}
183+
184+
func paymentParameters(order: Order,
185+
orderTotal: NSDecimalNumber,
186+
country: String,
187+
statementDescriptor: String?,
188+
paymentMethodTypes: [String],
189+
stripeSmallestCurrencyUnitMultiplier: Decimal) -> PaymentParameters {
190+
let metadata = PaymentIntent.initMetadata(
191+
store: stores.sessionManager.defaultSite?.name,
192+
customerName: buildCustomerNameFromBillingAddress(order.billingAddress),
193+
customerEmail: order.billingAddress?.email,
194+
siteURL: stores.sessionManager.defaultSite?.url,
195+
orderID: order.orderID,
196+
paymentType: PaymentIntent.PaymentTypes.single
197+
)
198+
199+
return PaymentParameters(amount: orderTotal as Decimal,
200+
currency: order.currency,
201+
stripeSmallestCurrencyUnitMultiplier: stripeSmallestCurrencyUnitMultiplier,
202+
applicationFee: applicationFee(for: orderTotal, country: country),
203+
receiptDescription: receiptDescription(orderNumber: order.number),
204+
statementDescription: statementDescriptor,
205+
receiptEmail: paymentReceiptEmailParameterDeterminer.receiptEmail(from: order),
206+
paymentMethodTypes: paymentMethodTypes,
207+
metadata: metadata)
208+
}
209+
210+
private func applicationFee(for orderTotal: NSDecimalNumber, country: String) -> Decimal? {
211+
guard country.uppercased() == SiteAddress.CountryCode.CA.rawValue else {
212+
return nil
213+
}
214+
215+
let fee = orderTotal.multiplying(by: Constants.canadaPercentageFee).adding(Constants.canadaFlatFee)
216+
217+
let numberHandler = NSDecimalNumberHandler(roundingMode: .plain,
218+
scale: 2,
219+
raiseOnExactness: false,
220+
raiseOnOverflow: false,
221+
raiseOnUnderflow: false,
222+
raiseOnDivideByZero: false)
223+
return fee.rounding(accordingToBehavior: numberHandler) as Decimal
224+
}
225+
226+
func receiptDescription(orderNumber: String) -> String? {
227+
guard let storeName = stores.sessionManager.defaultSite?.name,
228+
let blogID = stores.sessionManager.defaultSite?.siteID else {
229+
return nil
230+
}
231+
232+
return String.localizedStringWithFormat(Localization.receiptDescription,
233+
orderNumber,
234+
storeName,
235+
String(blogID))
236+
}
237+
238+
func celebrate() {
239+
celebration.celebrate()
240+
}
241+
242+
private func buildCustomerNameFromBillingAddress(_ address: Address?) -> String {
243+
var personNameComponents = PersonNameComponents()
244+
personNameComponents.givenName = address?.firstName
245+
personNameComponents.familyName = address?.lastName
246+
return personNameComponentsFormatter.string(from: personNameComponents)
247+
}
248+
}
249+
250+
private extension LegacyPaymentCaptureOrchestrator {
251+
enum Constants {
252+
static let canadaFlatFee = NSDecimalNumber(string: "0.15")
253+
static let canadaPercentageFee = NSDecimalNumber(0)
254+
}
255+
}
256+
257+
private extension LegacyPaymentCaptureOrchestrator {
258+
enum Localization {
259+
static let receiptDescription = NSLocalizedString(
260+
"In-Person Payment for Order #%1$@ for %2$@ blog_id %3$@",
261+
comment: "Message included in emailed receipts. " +
262+
"Reads as: In-Person Payment for Order @{number} for @{store name} blog_id @{blog ID} " +
263+
"Parameters: %1$@ - order number, %2$@ - store name, %3$@ - blog ID number")
264+
}
265+
}

WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ final class CollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProtocol {
8787

8888
/// IPP payments collector.
8989
///
90-
private lazy var paymentOrchestrator = PaymentCaptureOrchestrator(stores: stores)
90+
private lazy var paymentOrchestrator = LegacyPaymentCaptureOrchestrator(stores: stores)
9191

9292
/// Coordinates emailing a receipt after payment success.
9393
private var receiptEmailCoordinator: CardPresentPaymentReceiptEmailCoordinator?

WooCommerce/Classes/ViewRelated/Orders/Collect Payments/LegacyCollectOrderPaymentUseCase.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ final class LegacyCollectOrderPaymentUseCase: NSObject, CollectOrderPaymentProto
6666

6767
/// IPP payments collector.
6868
///
69-
private lazy var paymentOrchestrator = PaymentCaptureOrchestrator(stores: stores)
69+
private lazy var paymentOrchestrator = LegacyPaymentCaptureOrchestrator(stores: stores)
7070

7171
/// Controller to connect a card reader.
7272
///

0 commit comments

Comments
 (0)