Skip to content

Commit 361f4cc

Browse files
committed
8321 New PaymentCaptureOrchestrator for refactor
1 parent ad60e84 commit 361f4cc

File tree

3 files changed

+278
-9
lines changed

3 files changed

+278
-9
lines changed

WooCommerce/Classes/ViewModels/CardPresentPayments/LegacyPaymentCaptureOrchestrator.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,6 @@ import Yosemite
22
import PassKit
33
import WooFoundation
44

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

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@
502502
03E471C8293A3076001A58AD /* CardPresentModalBuiltInConnectingToReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E471C7293A3075001A58AD /* CardPresentModalBuiltInConnectingToReader.swift */; };
503503
03E471CA293E0A30001A58AD /* CardPresentModalBuiltInConfigurationProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E471C9293E0A2F001A58AD /* CardPresentModalBuiltInConfigurationProgress.swift */; };
504504
03E471CC293E0FB8001A58AD /* CardPresentModalProgressDisplaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E471CB293E0FB8001A58AD /* CardPresentModalProgressDisplaying.swift */; };
505+
03E471CE293F63B4001A58AD /* PaymentCaptureOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E471CD293F63B4001A58AD /* PaymentCaptureOrchestrator.swift */; };
505506
03EF24FA28BF5D21006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF24F928BF5D21006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift */; };
506507
03EF24FC28BF996F006A033E /* InPersonPaymentsCashOnDeliveryPaymentGatewayHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF24FB28BF996F006A033E /* InPersonPaymentsCashOnDeliveryPaymentGatewayHelpers.swift */; };
507508
03EF24FE28C0B356006A033E /* CardPresentPaymentsPlugin+CashOnDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF24FD28C0B356006A033E /* CardPresentPaymentsPlugin+CashOnDelivery.swift */; };
@@ -2515,6 +2516,7 @@
25152516
03E471C7293A3075001A58AD /* CardPresentModalBuiltInConnectingToReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardPresentModalBuiltInConnectingToReader.swift; sourceTree = "<group>"; };
25162517
03E471C9293E0A2F001A58AD /* CardPresentModalBuiltInConfigurationProgress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardPresentModalBuiltInConfigurationProgress.swift; sourceTree = "<group>"; };
25172518
03E471CB293E0FB8001A58AD /* CardPresentModalProgressDisplaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalProgressDisplaying.swift; sourceTree = "<group>"; };
2519+
03E471CD293F63B4001A58AD /* PaymentCaptureOrchestrator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentCaptureOrchestrator.swift; sourceTree = "<group>"; };
25182520
03EF24F928BF5D21006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift; sourceTree = "<group>"; };
25192521
03EF24FB28BF996F006A033E /* InPersonPaymentsCashOnDeliveryPaymentGatewayHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsCashOnDeliveryPaymentGatewayHelpers.swift; sourceTree = "<group>"; };
25202522
03EF24FD28C0B356006A033E /* CardPresentPaymentsPlugin+CashOnDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardPresentPaymentsPlugin+CashOnDelivery.swift"; sourceTree = "<group>"; };
@@ -8568,6 +8570,7 @@
85688570
E16715CA26663B0B00326230 /* CardPresentModalSuccessWithoutEmail.swift */,
85698571
D8815B122638686200EDAD62 /* CardPresentModalError.swift */,
85708572
D802541E2655137A001B2CC1 /* CardPresentModalNonRetryableError.swift */,
8573+
03E471CD293F63B4001A58AD /* PaymentCaptureOrchestrator.swift */,
85718574
D85806282642BA5400A8AB6C /* LegacyPaymentCaptureOrchestrator.swift */,
85728575
B9C4AB2628002AF3007008B8 /* PaymentReceiptEmailParameterDeterminer.swift */,
85738576
D82BB3A926454F3300A82741 /* CardPresentModalProcessing.swift */,
@@ -10814,6 +10817,7 @@
1081410817
E10BC15E26CC06970064F5E2 /* ScrollableVStack.swift in Sources */,
1081510818
B50BB4162141828F00AF0F3C /* FooterSpinnerView.swift in Sources */,
1081610819
D8610CE2257099E100A5DF27 /* FancyAlertViewController+UnifiedLogin.swift in Sources */,
10820+
03E471CE293F63B4001A58AD /* PaymentCaptureOrchestrator.swift in Sources */,
1081710821
B5980A6321AC879F00EBF596 /* Bundle+Woo.swift in Sources */,
1081810822
B59D1EE5219080B4009D1978 /* Note+Woo.swift in Sources */,
1081910823
02913E9523A774C500707A0C /* UnitInputFormatter.swift in Sources */,

0 commit comments

Comments
 (0)