Skip to content

Commit b860ee2

Browse files
committed
Merge branch 'trunk' into feat/5983-usecase-unit-tests
# Conflicts: # WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift # WooCommerce/WooCommerce.xcodeproj/project.pbxproj
2 parents e006e25 + 667d162 commit b860ee2

File tree

47 files changed

+2801
-1324
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2801
-1324
lines changed

Hardware/Hardware.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
55CD4BB4273E617C007686D3 /* ReceiptRendererTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55CD4BB3273E617C007686D3 /* ReceiptRendererTest.swift */; };
2222
5A747BE9FA06EC8752A35752 /* Pods_HardwareTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1DC5B6141B8184FAC29B0A4 /* Pods_HardwareTests.framework */; };
2323
8FFAA245E257B9EB98E2FCBD /* Pods_SampleReceiptPrinter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AFA997D6786C67B0A061854 /* Pods_SampleReceiptPrinter.framework */; };
24+
B9C4AB2327FDE133007008B8 /* Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C4AB2227FDE133007008B8 /* Email.swift */; };
2425
C5D2CB7D21CEE28FEBF18BF6 /* Pods_Hardware.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9F0AC202B287C1221EA2C99 /* Pods_Hardware.framework */; platformFilter = ios; };
2526
D80409A625FBE42B006F9BDA /* PaymentIntentParameters+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80409A525FBE42B006F9BDA /* PaymentIntentParameters+Stripe.swift */; };
2627
D80B4652260E19590092EDC0 /* PaymentIntentParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80B4651260E19590092EDC0 /* PaymentIntentParametersTests.swift */; };
@@ -156,6 +157,7 @@
156157
9726331F55A9621F2F887E13 /* Pods-Hardware.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Hardware.release.xcconfig"; path = "Target Support Files/Pods-Hardware/Pods-Hardware.release.xcconfig"; sourceTree = "<group>"; };
157158
AE60F16D43C20AD4523A61A5 /* Pods-SampleReceiptPrinter.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SampleReceiptPrinter.debug.xcconfig"; path = "Target Support Files/Pods-SampleReceiptPrinter/Pods-SampleReceiptPrinter.debug.xcconfig"; sourceTree = "<group>"; };
158159
B1DC5B6141B8184FAC29B0A4 /* Pods_HardwareTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HardwareTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
160+
B9C4AB2227FDE133007008B8 /* Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Email.swift; sourceTree = "<group>"; };
159161
C61D1642BE09D1A1AD6AA9FA /* Pods-HardwareTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HardwareTests.release.xcconfig"; path = "Target Support Files/Pods-HardwareTests/Pods-HardwareTests.release.xcconfig"; sourceTree = "<group>"; };
160162
C810BBAD03E7D4ECFD29D7AC /* Pods-SampleReceiptPrinter.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SampleReceiptPrinter.release-alpha.xcconfig"; path = "Target Support Files/Pods-SampleReceiptPrinter/Pods-SampleReceiptPrinter.release-alpha.xcconfig"; sourceTree = "<group>"; };
161163
D80409A525FBE42B006F9BDA /* PaymentIntentParameters+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentIntentParameters+Stripe.swift"; sourceTree = "<group>"; };
@@ -426,6 +428,7 @@
426428
D845BDC1262D98C400A3E40F /* CardBrand.swift */,
427429
D845BDD9262DAADB00A3E40F /* PaymentMethod.swift */,
428430
317975BF274EB1F9004357B1 /* DeclineReason.swift */,
431+
B9C4AB2227FDE133007008B8 /* Email.swift */,
429432
);
430433
path = CardReader;
431434
sourceTree = "<group>";
@@ -768,6 +771,7 @@
768771
isa = PBXSourcesBuildPhase;
769772
buildActionMask = 2147483647;
770773
files = (
774+
B9C4AB2327FDE133007008B8 /* Email.swift in Sources */,
771775
D845BE59262ED84000A3E40F /* AirPrintReceiptPrinterService.swift in Sources */,
772776
317975C2274EBC1F004357B1 /* DeclineReason+Stripe.swift in Sources */,
773777
D845BDB8262D97B300A3E40F /* ReceiptDetails.swift in Sources */,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/// A property wrapper to validate that a property is a valid email
2+
/// Property Wrappers can not throw, so
3+
/// what this wrapper does is return a nil when trying to set an invalid
4+
/// email address as a value of a property of type String.
5+
/// The reason to do this is add an extra layer of validation before passing
6+
/// an instance of PaymentIntentParameters to the Stripe Terminal SDK
7+
/// https://emailregex.com
8+
@propertyWrapper
9+
public struct Email<Value: StringProtocol> {
10+
var value: Value?
11+
12+
public init(wrappedValue value: Value?) {
13+
self.value = value
14+
}
15+
16+
public var wrappedValue: Value? {
17+
get {
18+
return validate(email: value) ? value : nil
19+
}
20+
set {
21+
value = newValue
22+
}
23+
}
24+
private func validate(email: Value?) -> Bool {
25+
guard let email = email else { return false }
26+
// https://emailregex.com
27+
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
28+
let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx)
29+
return emailPred.evaluate(with: email)
30+
}
31+
}

Hardware/Hardware/CardReader/PaymentIntentParameters.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public struct PaymentIntentParameters {
2424
@StatementDescriptor
2525
public private(set) var statementDescription: String?
2626

27+
/// Email address that the receipt for the resulting payment will be sent to.
28+
@Email
29+
public private(set) var receiptEmail: String?
30+
2731
/// Set of key-value pairs that you can attach to an object.
2832
/// This can be useful for storing additional information about the object in a structured format.
2933
public let metadata: [AnyHashable: Any]?
@@ -38,12 +42,14 @@ public struct PaymentIntentParameters {
3842
currency: String,
3943
receiptDescription: String? = nil,
4044
statementDescription: String? = nil,
45+
receiptEmail: String? = nil,
4146
paymentMethodTypes: [String] = [],
4247
metadata: [AnyHashable: Any]? = nil) {
4348
self.amount = amount
4449
self.currency = currency
4550
self.receiptDescription = receiptDescription
4651
self.statementDescription = statementDescription
52+
self.receiptEmail = receiptEmail
4753
self.paymentMethodTypes = paymentMethodTypes
4854
self.metadata = metadata
4955
}

Hardware/Hardware/CardReader/StripeCardReader/PaymentIntentParameters+Stripe.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ extension Hardware.PaymentIntentParameters {
3232
returnValue.statementDescriptor = descriptor
3333
}
3434

35+
returnValue.receiptEmail = receiptEmail
3536
returnValue.metadata = metadata
3637

3738
return returnValue

Hardware/HardwareTests/PaymentIntentParametersTests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import XCTest
22
@testable import Hardware
33

44
final class PaymentIntentParametersTests: XCTestCase {
5+
func test_validEmail_is_saved() {
6+
let params = PaymentIntentParameters(amount: 100, currency: "usd", receiptEmail: "[email protected]", paymentMethodTypes: ["card_present"])
7+
8+
XCTAssertNotNil(params.receiptEmail)
9+
}
10+
11+
func test_not_validEmail_is_ignored() {
12+
let params = PaymentIntentParameters(amount: 100, currency: "usd", receiptEmail: "woocommerce", paymentMethodTypes: ["card_present"])
13+
14+
XCTAssertNil(params.receiptEmail)
15+
}
16+
517
func test_currency_is_lowercased() {
618
let params = PaymentIntentParameters(amount: 100, currency: "USD", paymentMethodTypes: ["card_present"])
719

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Yosemite
2+
import Foundation
3+
import Storage
4+
5+
/// Provides data and helper methods related to the Payments Plugins (WCPay, Stripe).
6+
/// It extracts the information from the provided `StorageManagerType`, but please notice that it does not
7+
/// take care of syncing the data, so it should be done beforehand.
8+
///
9+
protocol CardPresentPluginsDataProviderProtocol {
10+
func getWCPayPlugin() -> Yosemite.SystemPlugin?
11+
func getStripePlugin() -> Yosemite.SystemPlugin?
12+
func bothPluginsInstalledAndActive(wcPay: Yosemite.SystemPlugin?, stripe: Yosemite.SystemPlugin?) -> Bool
13+
func wcPayInstalledAndActive(wcPay: Yosemite.SystemPlugin?) -> Bool
14+
func stripeInstalledAndActive(stripe: Yosemite.SystemPlugin?) -> Bool
15+
func isWCPayVersionSupported(plugin: Yosemite.SystemPlugin) -> Bool
16+
func isStripeVersionSupported(plugin: Yosemite.SystemPlugin) -> Bool
17+
}
18+
19+
struct CardPresentPluginsDataProvider: CardPresentPluginsDataProviderProtocol {
20+
private let storageManager: StorageManagerType
21+
private let stores: StoresManager
22+
23+
init(
24+
storageManager: StorageManagerType = ServiceLocator.storageManager,
25+
stores: StoresManager = ServiceLocator.stores
26+
) {
27+
self.storageManager = storageManager
28+
self.stores = stores
29+
}
30+
31+
private var siteID: Int64? {
32+
stores.sessionManager.defaultStoreID
33+
}
34+
35+
func getWCPayPlugin() -> Yosemite.SystemPlugin? {
36+
guard let siteID = siteID else {
37+
return nil
38+
}
39+
return storageManager.viewStorage
40+
.loadSystemPlugin(siteID: siteID, name: CardPresentPaymentsPlugins.wcPay.pluginName)?
41+
.toReadOnly()
42+
}
43+
44+
func getStripePlugin() -> Yosemite.SystemPlugin? {
45+
guard let siteID = siteID else {
46+
return nil
47+
}
48+
return storageManager.viewStorage
49+
.loadSystemPlugin(siteID: siteID, name: CardPresentPaymentsPlugins.stripe.pluginName)?
50+
.toReadOnly()
51+
}
52+
53+
func bothPluginsInstalledAndActive(wcPay: Yosemite.SystemPlugin?, stripe: Yosemite.SystemPlugin?) -> Bool {
54+
guard let wcPay = wcPay, let stripe = stripe else {
55+
return false
56+
}
57+
58+
return wcPay.active && stripe.active
59+
}
60+
61+
func wcPayInstalledAndActive(wcPay: Yosemite.SystemPlugin?) -> Bool {
62+
// If the WCPay plugin is not installed, immediately return false
63+
guard let wcPay = wcPay else {
64+
return false
65+
}
66+
67+
return wcPay.active
68+
}
69+
70+
func stripeInstalledAndActive(stripe: Yosemite.SystemPlugin?) -> Bool {
71+
// If the Stripe plugin is not installed, immediately return false
72+
guard let stripe = stripe else {
73+
return false
74+
}
75+
76+
return stripe.active
77+
}
78+
79+
func isWCPayVersionSupported(plugin: Yosemite.SystemPlugin) -> Bool {
80+
VersionHelpers.isVersionSupported(version: plugin.version, minimumRequired: CardPresentPaymentsPlugins.wcPay.minimumSupportedPluginVersion)
81+
}
82+
83+
func isStripeVersionSupported(plugin: Yosemite.SystemPlugin) -> Bool {
84+
VersionHelpers.isVersionSupported(version: plugin.version, minimumRequired: CardPresentPaymentsPlugins.stripe.minimumSupportedPluginVersion)
85+
}
86+
}

WooCommerce/Classes/ViewModels/CardPresentPayments/PaymentCaptureOrchestrator.swift

Lines changed: 73 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ struct CardPresentCapturedPaymentData {
1919
final class PaymentCaptureOrchestrator {
2020
private let currencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)
2121
private let personNameComponentsFormatter = PersonNameComponentsFormatter()
22+
private let paymentReceiptEmailParameterDeterminer = PaymentReceiptEmailParameterDeterminer()
2223

2324
private let celebration = PaymentCaptureCelebration()
2425

@@ -51,47 +52,50 @@ final class PaymentCaptureOrchestrator {
5152

5253
stores.dispatch(setAccount)
5354

54-
guard let parameters = paymentParameters(
55+
paymentParameters(
5556
order: order,
5657
statementDescriptor: paymentGatewayAccount.statementDescriptor,
5758
paymentMethodTypes: paymentMethodTypes
58-
) else {
59-
DDLogError("Error: failed to create payment parameters for an order")
60-
onCompletion(.failure(CardReaderServiceError.paymentCapture()))
61-
return
62-
}
59+
) { [weak self] result in
60+
guard let self = self else { return }
6361

64-
/// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the
65-
/// reader begins to collect payment.
66-
///
67-
suppressPassPresentation()
68-
69-
let paymentAction = CardPresentPaymentAction.collectPayment(
70-
siteID: order.siteID,
71-
orderID: order.orderID,
72-
parameters: parameters,
73-
onCardReaderMessage: { (event) in
74-
switch event {
75-
case .waitingForInput:
76-
onWaitingForInput()
77-
case .displayMessage(let message):
78-
onDisplayMessage(message)
79-
default:
80-
break
81-
}
82-
},
83-
onCompletion: { [weak self] result in
84-
self?.allowPassPresentation()
85-
onProcessingMessage()
86-
self?.completePaymentIntentCapture(
87-
order: order,
88-
captureResult: result,
89-
onCompletion: onCompletion
62+
switch result {
63+
case let .success(parameters):
64+
/// Briefly suppress pass (wallet) presentation so that the merchant doesn't attempt to pay for the buyer's order when the
65+
/// reader begins to collect payment.
66+
///
67+
self.suppressPassPresentation()
68+
69+
let paymentAction = CardPresentPaymentAction.collectPayment(
70+
siteID: order.siteID,
71+
orderID: order.orderID,
72+
parameters: parameters,
73+
onCardReaderMessage: { (event) in
74+
switch event {
75+
case .waitingForInput:
76+
onWaitingForInput()
77+
case .displayMessage(let message):
78+
onDisplayMessage(message)
79+
default:
80+
break
81+
}
82+
},
83+
onCompletion: { [weak self] result in
84+
self?.allowPassPresentation()
85+
onProcessingMessage()
86+
self?.completePaymentIntentCapture(
87+
order: order,
88+
captureResult: result,
89+
onCompletion: onCompletion
90+
)
91+
}
9092
)
91-
}
92-
)
9393

94-
stores.dispatch(paymentAction)
94+
self.stores.dispatch(paymentAction)
95+
case let .failure(error):
96+
onCompletion(Result.failure(error))
97+
}
98+
}
9599
}
96100

97101
func cancelPayment(onCompletion: @escaping (Result<Void, Error>) -> Void) {
@@ -216,27 +220,44 @@ private extension PaymentCaptureOrchestrator {
216220
stores.dispatch(action)
217221
}
218222

219-
func paymentParameters(order: Order, statementDescriptor: String?, paymentMethodTypes: [String]) -> PaymentParameters? {
223+
func paymentParameters(order: Order,
224+
statementDescriptor: String?,
225+
paymentMethodTypes: [String],
226+
onCompletion: @escaping ((Result<PaymentParameters, Error>) -> Void)) {
220227
guard let orderTotal = currencyFormatter.convertToDecimal(from: order.total) else {
221228
DDLogError("Error: attempted to collect payment for an order without a valid total.")
222-
return nil
229+
onCompletion(Result.failure(NotValidAmountError.other))
230+
231+
return
223232
}
224233

225-
let metadata = PaymentIntent.initMetadata(
226-
store: stores.sessionManager.defaultSite?.name,
227-
customerName: buildCustomerNameFromBillingAddress(order.billingAddress),
228-
customerEmail: order.billingAddress?.email,
229-
siteURL: stores.sessionManager.defaultSite?.url,
230-
orderID: order.orderID,
231-
paymentType: PaymentIntent.PaymentTypes.single
232-
)
233-
234-
return PaymentParameters(amount: orderTotal as Decimal,
235-
currency: order.currency,
236-
receiptDescription: receiptDescription(orderNumber: order.number),
237-
statementDescription: statementDescriptor,
238-
paymentMethodTypes: paymentMethodTypes,
239-
metadata: metadata)
234+
paymentReceiptEmailParameterDeterminer.receiptEmail(from: order) { [weak self] result in
235+
guard let self = self else { return }
236+
237+
var receiptEmail: String?
238+
if case let .success(email) = result {
239+
receiptEmail = email
240+
}
241+
242+
let metadata = PaymentIntent.initMetadata(
243+
store: self.stores.sessionManager.defaultSite?.name,
244+
customerName: self.buildCustomerNameFromBillingAddress(order.billingAddress),
245+
customerEmail: order.billingAddress?.email,
246+
siteURL: self.stores.sessionManager.defaultSite?.url,
247+
orderID: order.orderID,
248+
paymentType: PaymentIntent.PaymentTypes.single
249+
)
250+
251+
let parameters = PaymentParameters(amount: orderTotal as Decimal,
252+
currency: order.currency,
253+
receiptDescription: self.receiptDescription(orderNumber: order.number),
254+
statementDescription: statementDescriptor,
255+
receiptEmail: receiptEmail,
256+
paymentMethodTypes: paymentMethodTypes,
257+
metadata: metadata)
258+
259+
onCompletion(Result.success(parameters))
260+
}
240261
}
241262

242263
func receiptDescription(orderNumber: String) -> String? {

0 commit comments

Comments
 (0)