Skip to content

Commit 7660350

Browse files
authored
Vault without Purchase (#172)
* vault without purchase * clean up cardClient initializer * Use associatedType for variables for GraphQLQuery protocol * moving setuptoken call to demo app * use APIRequest for merchantServer setupToken request * use enum for PaymentSourceInput for different payment options * get request for setup token details * move graphQL request, responses from Core to Card module * remove APIRequest use in demo app * Add VaultCardDelegate for VaultCardResult * Add payment token creation in demo app * remove duplicate UpdateSetupTokenQuery.swift * rename UpdateSetupTokenRequest to UpdateSetupTokenQuery * added newly named file reference to project * add mocks for unit tests, pr feedback * disable swiftlint for multiline query, target for card test * correct target for MockGraphQLClient * CardClient vault error tests * docStrings and CHANGELOG * PR feedback * PR feedback * PR feedback nodoc String for GraphQLQuery requestBody() * spacing in paymentTokenRequest * PR feedback * PR feedback from Jax 8/16/23 * PR feedback from Jax cleanup
1 parent a305fa5 commit 7660350

27 files changed

+694
-33
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11

22
# PayPal iOS SDK Release Notes
33

4+
## unreleased
5+
* CardPayments
6+
* Add `vault` method
7+
* Add `CardVaultRequest` and `CardVaultResult` types for interacting with `vault` method
8+
* Add `CardVaultDelegate` protocol to receive success and failure results
9+
* Add `CardVaultDelegate` property to `CardClient`
10+
411
## 0.0.10 (2023-08-14)
512
* PayPalNativePayments
613
* Bump `PayPalCheckout` to `1.0.0`

Demo/Demo.xcodeproj/project.pbxproj

+16
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
3B22E8BA2A842D8900962E34 /* PaymentTokenRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B22E8B92A842D8900962E34 /* PaymentTokenRequest.swift */; };
11+
3B22E8BC2A84397600962E34 /* PaymentTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B22E8BB2A84397600962E34 /* PaymentTokenResponse.swift */; };
1012
3B80D50E2A291C0800D2EAC4 /* ClientIDRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B80D50D2A291C0800D2EAC4 /* ClientIDRequest.swift */; };
1113
3B80D5102A291CB100D2EAC4 /* ClientIDResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B80D50F2A291CB100D2EAC4 /* ClientIDResponse.swift */; };
1214
3BB7A9772A5CA6FD00C05140 /* MerchantIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BB7A9762A5CA6FD00C05140 /* MerchantIntegration.swift */; };
15+
3BDB348E2A7CB02C008100D7 /* SetupTokenRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDB348D2A7CB02C008100D7 /* SetupTokenRequest.swift */; };
16+
3BDB34922A7CB5DE008100D7 /* SetupTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDB34912A7CB5DE008100D7 /* SetupTokenResponse.swift */; };
1317
5301468C28918B4D00184F22 /* ApprovalResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5301468B28918B4D00184F22 /* ApprovalResult.swift */; };
1418
536A5CA82898AA2A005C053D /* SwiftUINativeCheckoutDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536A5CA72898AA2A005C053D /* SwiftUINativeCheckoutDemo.swift */; };
1519
53B9E8EA28C93B4400719239 /* OrderRequestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53B9E8E928C93B4400719239 /* OrderRequestHelpers.swift */; };
@@ -97,9 +101,13 @@
97101
/* End PBXCopyFilesBuildPhase section */
98102

99103
/* Begin PBXFileReference section */
104+
3B22E8B92A842D8900962E34 /* PaymentTokenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTokenRequest.swift; sourceTree = "<group>"; };
105+
3B22E8BB2A84397600962E34 /* PaymentTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTokenResponse.swift; sourceTree = "<group>"; };
100106
3B80D50D2A291C0800D2EAC4 /* ClientIDRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientIDRequest.swift; sourceTree = "<group>"; };
101107
3B80D50F2A291CB100D2EAC4 /* ClientIDResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientIDResponse.swift; sourceTree = "<group>"; };
102108
3BB7A9762A5CA6FD00C05140 /* MerchantIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantIntegration.swift; sourceTree = "<group>"; };
109+
3BDB348D2A7CB02C008100D7 /* SetupTokenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupTokenRequest.swift; sourceTree = "<group>"; };
110+
3BDB34912A7CB5DE008100D7 /* SetupTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupTokenResponse.swift; sourceTree = "<group>"; };
103111
5301468B28918B4D00184F22 /* ApprovalResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalResult.swift; sourceTree = "<group>"; };
104112
536A5CA22898A48C005C053D /* PayPalNativeCheckout.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = PayPalNativeCheckout.framework; sourceTree = BUILT_PRODUCTS_DIR; };
105113
536A5CA72898AA2A005C053D /* SwiftUINativeCheckoutDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINativeCheckoutDemo.swift; sourceTree = "<group>"; };
@@ -271,6 +279,10 @@
271279
80F33CEC26F8E7A9006811B1 /* Order.swift */,
272280
80F33CF026F8E7D9006811B1 /* ProcessOrderParams.swift */,
273281
CBC16DD829ED90B600307117 /* UpdateOrderParams.swift */,
282+
3BDB348D2A7CB02C008100D7 /* SetupTokenRequest.swift */,
283+
3BDB34912A7CB5DE008100D7 /* SetupTokenResponse.swift */,
284+
3B22E8B92A842D8900962E34 /* PaymentTokenRequest.swift */,
285+
3B22E8BB2A84397600962E34 /* PaymentTokenResponse.swift */,
274286
);
275287
path = Models;
276288
sourceTree = "<group>";
@@ -481,6 +493,7 @@
481493
CB34B32328BE3A9A001325B9 /* PayPalViewModel.swift in Sources */,
482494
BED04233271084DF00C80954 /* CardFormatter.swift in Sources */,
483495
CB9ED44E28411B120081F4DE /* SwiftUIPaymentButtonDemo.swift in Sources */,
496+
3B22E8BC2A84397600962E34 /* PaymentTokenResponse.swift in Sources */,
484497
80561B3E26FB72D80023138C /* FeatureBaseViewController.swift in Sources */,
485498
BE9F36DC274578D100AFC7DA /* FeatureBaseViewControllerRepresentable.swift in Sources */,
486499
BECD84A027036DC2007CCAE4 /* Environment.swift in Sources */,
@@ -490,6 +503,7 @@
490503
CB9ED44C283FDA900081F4DE /* PaymentButtonEnums+Extension.swift in Sources */,
491504
BED041B1270CB33900C80954 /* CustomTextField.swift in Sources */,
492505
3B80D50E2A291C0800D2EAC4 /* ClientIDRequest.swift in Sources */,
506+
3BDB348E2A7CB02C008100D7 /* SetupTokenRequest.swift in Sources */,
493507
80F33CF326F8EA50006811B1 /* DemoSettings.swift in Sources */,
494508
BEDE304A275EA33500D275FD /* UIViewController+Extension.swift in Sources */,
495509
80F33CE826F8DE29006811B1 /* DemoMerchantAPI.swift in Sources */,
@@ -498,10 +512,12 @@
498512
BED041AF270CA0FB00C80954 /* CustomButton.swift in Sources */,
499513
3BB7A9772A5CA6FD00C05140 /* MerchantIntegration.swift in Sources */,
500514
BE1766B326F911A2007EF438 /* URLResponseError.swift in Sources */,
515+
3BDB34922A7CB5DE008100D7 /* SetupTokenResponse.swift in Sources */,
501516
CBC16DD929ED90B600307117 /* UpdateOrderParams.swift in Sources */,
502517
BE9F36D82745490400AFC7DA /* FloatingLabelTextField.swift in Sources */,
503518
BE9F36E7275548A600AFC7DA /* BaseViewModel.swift in Sources */,
504519
806F1E3926B85367007A60E6 /* AppDelegate.swift in Sources */,
520+
3B22E8BA2A842D8900962E34 /* PaymentTokenRequest.swift in Sources */,
505521
536A5CA82898AA2A005C053D /* SwiftUINativeCheckoutDemo.swift in Sources */,
506522
806F1E3B26B85367007A60E6 /* SceneDelegate.swift in Sources */,
507523
BC6460CD2A12A2A0002B974B /* EmptyBodyParams.swift in Sources */,
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
3+
struct PaymentTokenRequest {
4+
5+
let setupToken: String
6+
7+
var path: String {
8+
"/payment_tokens/"
9+
}
10+
11+
var method: String {
12+
"POST"
13+
}
14+
15+
var headers: [String: String] {
16+
["Content-Type": "application/json"]
17+
}
18+
19+
var body: Data? {
20+
let requestBody: [String: Any] = [
21+
"payment_source": [
22+
"token": [
23+
"id": setupToken,
24+
"type": "SETUP_TOKEN"
25+
]
26+
]
27+
]
28+
29+
return try? JSONSerialization.data(withJSONObject: requestBody)
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
3+
struct PaymentTokenResponse: Decodable {
4+
5+
let id: String
6+
let customer: Customer
7+
let paymentSource: PaymentSource
8+
9+
10+
enum CodingKeys: String, CodingKey {
11+
case id
12+
case customer
13+
case paymentSource = "payment_source"
14+
}
15+
}
16+
17+
struct Customer: Decodable {
18+
19+
let id: String
20+
}
21+
22+
struct PaymentSource: Decodable {
23+
24+
let card: BasicCard
25+
}
26+
27+
struct BasicCard: Decodable {
28+
29+
let brand: String?
30+
let lastDigits: String
31+
let expiry: String
32+
33+
enum CodingKeys: String, CodingKey {
34+
case brand
35+
case lastDigits = "last_digits"
36+
case expiry
37+
}
38+
}
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Foundation
2+
3+
struct SetUpTokenRequest {
4+
5+
let customerID: String?
6+
7+
var path: String {
8+
"/setup_tokens/"
9+
}
10+
11+
var method: String {
12+
"POST"
13+
}
14+
15+
var headers: [String: String] {
16+
["Content-Type": "application/json"]
17+
}
18+
19+
var body: Data? {
20+
let requestBody: [String: Any] = [
21+
"customer": [
22+
"id": customerID
23+
],
24+
"payment_source": [
25+
"card": [:],
26+
"experience_context": [
27+
"returnUrl": "https://example.com/returnUrl",
28+
"cancelUrl": "https://example.com/returnUrl"
29+
]
30+
]
31+
]
32+
33+
return try? JSONSerialization.data(withJSONObject: requestBody)
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
struct SetUpTokenResponse: Decodable {
4+
5+
let id, status: String
6+
}

Demo/Demo/Networking/DemoMerchantAPI.swift

+82-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,39 @@ final class DemoMerchantAPI {
1919

2020
// MARK: Public Methods
2121

22+
func getSetupToken(customerID: String? = nil, selectedMerchantIntegration: MerchantIntegration) async throws -> SetUpTokenResponse {
23+
do {
24+
let request = SetUpTokenRequest(customerID: customerID)
25+
let urlRequest = try createSetupTokenUrlRequest(
26+
setupTokenRequest: request,
27+
environment: DemoSettings.environment,
28+
selectedMerchantIntegration: selectedMerchantIntegration
29+
)
30+
31+
let data = try await data(for: urlRequest)
32+
return try parse(from: data)
33+
} catch {
34+
print("error with the create setup token request: \(error.localizedDescription)")
35+
throw error
36+
}
37+
}
38+
39+
func getPaymentToken(setupToken: String, selectedMerchantIntegration: MerchantIntegration) async throws -> PaymentTokenResponse {
40+
do {
41+
let request = PaymentTokenRequest(setupToken: setupToken)
42+
let urlRequest = try createPaymentTokenUrlRequest(
43+
paymentTokenRequest: request,
44+
environment: DemoSettings.environment,
45+
selectedMerchantIntegration: selectedMerchantIntegration
46+
)
47+
let data = try await data(for: urlRequest)
48+
return try parse(from: data)
49+
} catch {
50+
print("error with the create payment token request: \(error.localizedDescription)")
51+
throw error
52+
}
53+
}
54+
2255
func captureOrder(orderID: String, selectedMerchantIntegration: MerchantIntegration) async throws -> Order {
2356
guard let url = buildBaseURL(with: "/orders/\(orderID)/capture", selectedMerchantIntegration: selectedMerchantIntegration) else {
2457
throw URLResponseError.invalidURL
@@ -168,7 +201,9 @@ final class DemoMerchantAPI {
168201
}
169202

170203
private func createUrlRequest(
171-
clientIDRequest: ClientIDRequest, environment: Demo.Environment, selectedMerchantIntegration: MerchantIntegration
204+
clientIDRequest: ClientIDRequest,
205+
environment: Demo.Environment,
206+
selectedMerchantIntegration: MerchantIntegration
172207
) throws -> URLRequest {
173208
var completeUrl = environment.baseURL
174209

@@ -185,4 +220,50 @@ final class DemoMerchantAPI {
185220
}
186221
return request
187222
}
223+
224+
private func createSetupTokenUrlRequest(
225+
setupTokenRequest: SetUpTokenRequest,
226+
environment: Demo.Environment,
227+
selectedMerchantIntegration: MerchantIntegration
228+
) throws -> URLRequest {
229+
var completeUrl = environment.baseURL
230+
completeUrl += selectedMerchantIntegration.path
231+
completeUrl.append(contentsOf: setupTokenRequest.path)
232+
233+
guard let url = URL(string: completeUrl) else {
234+
throw URLResponseError.invalidURL
235+
}
236+
237+
var request = URLRequest(url: url)
238+
request.httpMethod = setupTokenRequest.method
239+
request.httpBody = setupTokenRequest.body
240+
setupTokenRequest.headers.forEach { key, value in
241+
request.addValue(value, forHTTPHeaderField: key)
242+
}
243+
244+
return request
245+
}
246+
247+
private func createPaymentTokenUrlRequest(
248+
paymentTokenRequest: PaymentTokenRequest,
249+
environment: Demo.Environment,
250+
selectedMerchantIntegration: MerchantIntegration
251+
) throws -> URLRequest {
252+
var completeUrl = environment.baseURL
253+
completeUrl += selectedMerchantIntegration.path
254+
completeUrl.append(contentsOf: paymentTokenRequest.path)
255+
256+
guard let url = URL(string: completeUrl) else {
257+
throw URLResponseError.invalidURL
258+
}
259+
260+
var request = URLRequest(url: url)
261+
request.httpMethod = paymentTokenRequest.method
262+
request.httpBody = paymentTokenRequest.body
263+
paymentTokenRequest.headers.forEach { key, value in
264+
request.addValue(value, forHTTPHeaderField: key)
265+
}
266+
267+
return request
268+
}
188269
}

Demo/Demo/SwiftUIComponents/SwiftUICardDemo.swift

+17
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ struct SwiftUICardDemo: View {
6969
)
7070
.cornerRadius(10)
7171
.disabled(!baseViewModel.isCardFormValid(cardNumber: cardNumberText, expirationDate: expirationDateText, cvv: cvvText))
72+
Button("Vault Card without Purchase") {
73+
guard let card = baseViewModel.createCard(
74+
cardNumber: cardNumberText,
75+
expirationDate: expirationDateText,
76+
cvv: cvvText
77+
) else {
78+
return
79+
}
80+
Task {
81+
await baseViewModel.vaultCard(card: card, customerID: vaultCustomerID.isEmpty ? nil : vaultCustomerID)
82+
}
83+
}
84+
.foregroundColor(.white)
85+
.padding()
86+
.frame(maxWidth: .infinity)
87+
.background(.blue)
88+
.cornerRadius(10)
7289
}
7390
.padding(.horizontal, 16)
7491
}

Demo/Demo/ViewModels/BaseViewModel.swift

+31-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import PayPalCheckout
88

99
/// This class is used to share the orderID across shared views, update the text of `bottomStatusLabel` in our `FeatureBaseViewController`
1010
/// as well as share the logic of `processOrder` across our duplicate (SwiftUI and UIKit) card views.
11-
class BaseViewModel: ObservableObject, PayPalWebCheckoutDelegate, CardDelegate {
11+
class BaseViewModel: ObservableObject, PayPalWebCheckoutDelegate, CardDelegate, CardVaultDelegate {
1212

1313
/// Weak reference to associated view
1414
weak var view: FeatureBaseViewController?
@@ -93,6 +93,22 @@ class BaseViewModel: ObservableObject, PayPalWebCheckoutDelegate, CardDelegate {
9393
let cardRequest = CardRequest(orderID: orderID, card: card, sca: .scaAlways)
9494
cardClient.approveOrder(request: cardRequest)
9595
}
96+
97+
func vaultCard(card: Card, customerID: String? = nil) async {
98+
do {
99+
guard let config = await getCoreConfig() else { return }
100+
let cardClient = CardClient(config: config)
101+
cardClient.vaultDelegate = self
102+
let tokenResponse = try await DemoMerchantAPI.sharedService.getSetupToken(
103+
customerID: customerID, selectedMerchantIntegration: selectedMerchantIntegration
104+
)
105+
106+
let cardVaultRequest = CardVaultRequest(card: card, setupTokenID: tokenResponse.id)
107+
cardClient.vault(cardVaultRequest)
108+
} catch {
109+
print("Error in getSetupToken: \(error.localizedDescription)")
110+
}
111+
}
96112

97113
func isCardFormValid(cardNumber: String, expirationDate: String, cvv: String) -> Bool {
98114
guard orderID != nil else {
@@ -216,6 +232,20 @@ class BaseViewModel: ObservableObject, PayPalWebCheckoutDelegate, CardDelegate {
216232
updateTitle("3DS challenge has finished")
217233
print("3DS challenge has finished")
218234
}
235+
236+
// MARK: - CardVault Delegate
237+
238+
func card(_ cardClient: CardClient, didFinishWithVaultResult vaultResult: CardVaultResult) {
239+
updateTitle(
240+
"Vault without Purchase has finished. \n SetupTokenID: \(vaultResult.setupTokenID) \nVault Status: \(vaultResult.status)"
241+
)
242+
print("Vault without purchase has finished: \(vaultResult)")
243+
}
244+
245+
func card(_ cardClient: CardClient, didFinishWithVaultError vaultError: CoreSDKError) {
246+
updateTitle("Vault without purchase has failed: \(vaultError.localizedDescription)")
247+
print("❌ There was an error: \(vaultError)")
248+
}
219249

220250
func getClientID() async -> String? {
221251
await DemoMerchantAPI.sharedService.getClientID(

0 commit comments

Comments
 (0)