diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 74ad85889..a4a9efbdd 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -227,6 +227,78 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1001E2AA2CF53C970023A03C /* CardPayments */ = { + isa = PBXGroup; + children = ( + 3BA56FEA2A9DCBB30081D14F /* CardPaymentViews */, + 1001E2AF2CF62EA20023A03C /* CardPaymentsViewModel */, + ); + path = CardPayments; + sourceTree = ""; + }; + 1001E2AB2CF53CB30023A03C /* CardVault */ = { + isa = PBXGroup; + children = ( + 3B43290F2A8FD7FD00C5441A /* CardVaultViews */, + 1001E2AE2CF53E540023A03C /* CardVaulthViewModel */, + ); + path = CardVault; + sourceTree = ""; + }; + 1001E2AC2CF53CC70023A03C /* PayPalVault */ = { + isa = PBXGroup; + children = ( + 3B2501052B2679F000903EAB /* VaultViews */, + 3BB60B512B1F9EE400A298CF /* PayPalVaultViews */, + 1001E2B02CF62EF40023A03C /* VaultViewModel */, + ); + path = PayPalVault; + sourceTree = ""; + }; + 1001E2AD2CF53CD70023A03C /* PayPalWebPayments */ = { + isa = PBXGroup; + children = ( + 3BA56FFF2A9FF6630081D14F /* PayPalWebPaymentsView */, + 1001E2B12CF6300F0023A03C /* PayPalWebViewModel */, + ); + path = PayPalWebPayments; + sourceTree = ""; + }; + 1001E2AE2CF53E540023A03C /* CardVaulthViewModel */ = { + isa = PBXGroup; + children = ( + 3B20273E2A89F24E0007907E /* CardVaultViewModel.swift */, + ); + path = CardVaulthViewModel; + sourceTree = ""; + }; + 1001E2AF2CF62EA20023A03C /* CardPaymentsViewModel */ = { + isa = PBXGroup; + children = ( + 3BA56FE62A9DC9D70081D14F /* CardPaymentViewModel.swift */, + 3BA56FE82A9DCA520081D14F /* CardPaymentState.swift */, + ); + path = CardPaymentsViewModel; + sourceTree = ""; + }; + 1001E2B02CF62EF40023A03C /* VaultViewModel */ = { + isa = PBXGroup; + children = ( + 3BB60B542B1FA00C00A298CF /* PayPalVaultViewModel.swift */, + 3BA0A58A2B1E240300330681 /* VaultViewModel.swift */, + 3B2027402A8A72050007907E /* VaultState.swift */, + ); + path = VaultViewModel; + sourceTree = ""; + }; + 1001E2B12CF6300F0023A03C /* PayPalWebViewModel */ = { + isa = PBXGroup; + children = ( + 3BA56FFB2A9FEFE90081D14F /* PayPalWebViewModel.swift */, + ); + path = PayPalWebViewModel; + sourceTree = ""; + }; 3B2501052B2679F000903EAB /* VaultViews */ = { isa = PBXGroup; children = ( @@ -263,7 +335,7 @@ path = CardPaymentViews; sourceTree = ""; }; - 3BA56FFF2A9FF6630081D14F /* PayPalWebPayments */ = { + 3BA56FFF2A9FF6630081D14F /* PayPalWebPaymentsView */ = { isa = PBXGroup; children = ( 3BA570062AA0DF330081D14F /* PayPalWebButtonsView.swift */, @@ -271,9 +343,8 @@ 3BA570002AA052E80081D14F /* PayPalWebPaymentsView.swift */, BE8117632B07E778009867B9 /* PayPalWebResultView.swift */, 3B6472A62AFAEB3A004745C4 /* PayPalWebTransactionView.swift */, - 3BA56FFB2A9FEFE90081D14F /* PayPalWebViewModel.swift */, ); - path = PayPalWebPayments; + path = PayPalWebPaymentsView; sourceTree = ""; }; 3BB60B512B1F9EE400A298CF /* PayPalVaultViews */ = { @@ -300,13 +371,6 @@ path = CommonComponents; sourceTree = ""; }; - 53B9E8E828C93B2B00719239 /* Helpers */ = { - isa = PBXGroup; - children = ( - ); - path = Helpers; - sourceTree = ""; - }; 805AB84C26B87A87003BEE0D /* Frameworks */ = { isa = PBXGroup; children = ( @@ -350,17 +414,19 @@ isa = PBXGroup; children = ( 806C7A812C000626000E85E8 /* Demo.entitlements */, - 53B9E8E828C93B2B00719239 /* Helpers */, 806F1E4126B85369007A60E6 /* Assets.xcassets */, BED0422F2710833100C80954 /* Card */, BECD849E27036D95007CCAE4 /* DemoSettings */, BEDE3048275EA31800D275FD /* Extensions */, 806F1E4626B85369007A60E6 /* Info.plist */, 80921C1C2710A0720040D76F /* LaunchScreen.storyboard */, + 1001E2AD2CF53CD70023A03C /* PayPalWebPayments */, + 1001E2AC2CF53CC70023A03C /* PayPalVault */, + 1001E2AB2CF53CB30023A03C /* CardVault */, + 1001E2AA2CF53C970023A03C /* CardPayments */, 80F33CEB26F8E799006811B1 /* Models */, BE1766B526FA562B007EF438 /* Networking */, BEDE3047275E998700D275FD /* SwiftUIComponents */, - BE4876A827567D4200802EAF /* ViewModels */, 3BCCFE482A9D96CA00C5102F /* DemoApp.swift */, ); path = Demo; @@ -411,19 +477,6 @@ path = Networking; sourceTree = ""; }; - BE4876A827567D4200802EAF /* ViewModels */ = { - isa = PBXGroup; - children = ( - 3B20273E2A89F24E0007907E /* CardVaultViewModel.swift */, - 3BA0A58A2B1E240300330681 /* VaultViewModel.swift */, - 3B2027402A8A72050007907E /* VaultState.swift */, - 3BA56FE62A9DC9D70081D14F /* CardPaymentViewModel.swift */, - 3BA56FE82A9DCA520081D14F /* CardPaymentState.swift */, - 3BB60B542B1FA00C00A298CF /* PayPalVaultViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; BECD849E27036D95007CCAE4 /* DemoSettings */ = { isa = PBXGroup; children = ( @@ -446,12 +499,7 @@ BEDE3047275E998700D275FD /* SwiftUIComponents */ = { isa = PBXGroup; children = ( - 3B2501052B2679F000903EAB /* VaultViews */, - 3BB60B512B1F9EE400A298CF /* PayPalVaultViews */, - 3BA56FFF2A9FF6630081D14F /* PayPalWebPayments */, - 3BA56FEA2A9DCBB30081D14F /* CardPaymentViews */, 3BCCFE472A9D962E00C5102F /* CommonComponents */, - 3B43290F2A8FD7FD00C5441A /* CardVaultViews */, CB9ED44D28411B110081F4DE /* SwiftUIPaymentButtonDemo.swift */, 3BCCFE4A2A9D985F00C5102F /* FeatureSelectionView.swift */, BE8117672B080472009867B9 /* CurrentState.swift */, diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardApprovalResultView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardApprovalResultView.swift new file mode 100644 index 000000000..bd19f2505 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardApprovalResultView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct CardApprovalResultView: View { + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + var body: some View { + switch cardPaymentViewModel.state.approveResultResponse { + case .idle, .loading: + EmptyView() + case .loaded(let approvalResult): + getSuccessView(approvalResult: approvalResult) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSuccessView(approvalResult: CardPaymentState.CardResult) -> some View { + VStack(spacing: 16) { + HStack { + Text("Card Approval Result") + .font(.system(size: 20)) + Spacer() + } + LeadingText("ID", weight: .bold) + LeadingText("\(approvalResult.id)") + if let status = approvalResult.status { + LeadingText("Order Status", weight: .bold) + LeadingText("\(status)") + } + LeadingText("didAttemptThreeDSecureAuthentication", weight: .bold) + LeadingText("\(approvalResult.didAttemptThreeDSecureAuthentication)") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardOrderActionButton.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderActionButton.swift new file mode 100644 index 000000000..e84a28acc --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderActionButton.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct CardOrderActionButton: View { + + let intent: Intent + let orderID: String + let selectedMerchantIntegration: MerchantIntegration + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + var body: some View { + ZStack { + Button("\(intent.rawValue)") { + completeOrder() + } + .buttonStyle(RoundedBlueButtonStyle()) + .padding() + + if .loading == cardPaymentViewModel.state.authorizedOrderResponse || + .loading == cardPaymentViewModel.state.capturedOrderResponse { + CircularProgressView() + } + } + } + + private func completeOrder() { + if intent == .capture { + Task { + do { + try await cardPaymentViewModel.captureOrder( + orderID: orderID, + selectedMerchantIntegration: selectedMerchantIntegration + ) + print("Order Captured. ID: \(cardPaymentViewModel.state.capturedOrder?.id ?? "")") + } catch { + print("Error capturing order: \(error.localizedDescription)") + } + } + } else { + Task { + do { + try await cardPaymentViewModel.authorizeOrder( + orderID: orderID, + selectedMerchantIntegration: selectedMerchantIntegration + ) + print("Order Authorized. ID: \(cardPaymentViewModel.state.authorizedOrder?.id ?? "")") + } catch { + print("Error authorizing order: \(error.localizedDescription)") + } + } + } + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardOrderApproveView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderApproveView.swift new file mode 100644 index 000000000..1fcc938d3 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderApproveView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import CardPayments +import CorePayments + +struct CardOrderApproveView: View { + + let cardSections: [CardSection] = [ + CardSection(title: "Successful Authentication Visa", numbers: ["4868 7194 6070 7704"]), + CardSection(title: "Vault with Purchase (no 3DS)", numbers: ["4000 0000 0000 0002"]), + CardSection(title: "Step up", numbers: ["5314 6090 4083 0349"]), + CardSection(title: "Frictionless - LiabilityShift Possible", numbers: ["4005 5192 0000 0004"]), + CardSection(title: "Frictionless - LiabilityShift NO", numbers: ["4020 0278 5185 3235"]), + CardSection(title: "No Challenge", numbers: ["4111 1111 1111 1111"]) + ] + let orderID: String + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + @State private var cardNumberText: String = "4111 1111 1111 1111" + @State private var expirationDateText: String = "01 / 25" + @State private var cvvText: String = "123" + + var body: some View { + ScrollView { + ScrollViewReader { scrollView in + VStack { + VStack(spacing: 16) { + HStack { + Text("Enter Card Information") + .font(.system(size: 20)) + Spacer() + } + + CardFormView( + cardSections: cardSections, + cardNumberText: $cardNumberText, + expirationDateText: $expirationDateText, + cvvText: $cvvText + ) + + let card = Card.createCard( + cardNumber: cardNumberText, + expirationDate: expirationDateText, + cvv: cvvText + ) + + Picker("SCA", selection: $cardPaymentViewModel.state.scaSelection) { + Text(SCA.scaWhenRequired.rawValue).tag(SCA.scaWhenRequired) + Text(SCA.scaAlways.rawValue).tag(SCA.scaAlways) + } + .pickerStyle(SegmentedPickerStyle()) + .frame(height: 50) + + ZStack { + Button("Approve Order") { + Task { + do { + await cardPaymentViewModel.checkoutWith( + card: card, + orderID: orderID, + sca: cardPaymentViewModel.state.scaSelection + ) + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if case .loading = cardPaymentViewModel.state.approveResultResponse { + CircularProgressView() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + CardApprovalResultView(cardPaymentViewModel: cardPaymentViewModel) + if cardPaymentViewModel.state.approveResult != nil { + NavigationLink { + CardPaymentOrderCompletionView(orderID: orderID, cardPaymentViewModel: cardPaymentViewModel) + } label: { + Text("Complete Order Transaction") + } + .buttonStyle(RoundedBlueButtonStyle()) + .padding() + } + Text("") + .id("bottomView") + Spacer() + } + .onChange(of: cardPaymentViewModel.state) { _ in + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardOrderCompletionResultView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderCompletionResultView.swift new file mode 100644 index 000000000..58853f88f --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardOrderCompletionResultView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct CardOrderCompletionResultView: View { + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + var body: some View { + switch cardPaymentViewModel.state.authorizedOrderResponse { + case .idle, .loading: + EmptyView() + case .loaded(let authorizedOrderResponse): + getOrderSuccessView(orderResponse: authorizedOrderResponse, intent: "Authorized") + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + + switch cardPaymentViewModel.state.capturedOrderResponse { + case .idle, .loading: + EmptyView() + case .loaded(let capturedOrderResponse): + getOrderSuccessView(orderResponse: capturedOrderResponse, intent: "Captured") + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getOrderSuccessView(orderResponse: Order, intent: String) -> some View { + VStack(spacing: 16) { + HStack { + Text("Order \(intent)") + .font(.system(size: 20)) + Spacer() + } + LeadingText("Order ID", weight: .bold) + LeadingText("\(orderResponse.id)") + LeadingText("Status", weight: .bold) + LeadingText("\(orderResponse.status)") + if let lastDigits = orderResponse.paymentSource?.card?.lastDigits { + LeadingText("Card Last Digits", weight: .bold) + LeadingText("\(lastDigits)") + } + if let brand = orderResponse.paymentSource?.card?.brand { + LeadingText("Brand", weight: .bold) + LeadingText("\(brand)") + } + if let vaultStatus = orderResponse.paymentSource?.card?.attributes?.vault.status { + LeadingText("Vault Status", weight: .bold) + LeadingText("\(vaultStatus)") + } + if let vaultID = orderResponse.paymentSource?.card?.attributes?.vault.id { + LeadingText("Vault ID / Payment Token", weight: .bold) + LeadingText("\(vaultID)") + } + if let customerID = orderResponse.paymentSource?.card?.attributes?.vault.customer?.id { + LeadingText("Customer ID", weight: .bold) + LeadingText("\(customerID)") + } + Text("") + .id("bottomView") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentOrderCompletionView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentOrderCompletionView.swift new file mode 100644 index 000000000..f65310061 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentOrderCompletionView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct CardPaymentOrderCompletionView: View { + + let orderID: String + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + var body: some View { + let state = cardPaymentViewModel.state + ScrollView { + ScrollViewReader { scrollView in + VStack { + CardApprovalResultView(cardPaymentViewModel: cardPaymentViewModel) + if state.approveResult != nil { + CardOrderActionButton( + intent: state.intent, + orderID: orderID, + selectedMerchantIntegration: DemoSettings.merchantIntegration, + cardPaymentViewModel: cardPaymentViewModel + ) + } + + if state.authorizedOrder != nil || state.capturedOrder != nil { + CardOrderCompletionResultView(cardPaymentViewModel: cardPaymentViewModel) + } + Text("") + .id("bottomView") + Spacer() + } + .onChange(of: state) { _ in + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentView.swift new file mode 100644 index 000000000..38e933d94 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CardPaymentView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct CardPaymentView: View { + + @StateObject var cardPaymentViewModel = CardPaymentViewModel() + + var body: some View { + ScrollView { + VStack(spacing: 16) { + CreateOrderCardPaymentView( + cardPaymentViewModel: cardPaymentViewModel, + selectedMerchantIntegration: DemoSettings.merchantIntegration + ) + + if let order = cardPaymentViewModel.state.createOrder { + OrderCreateCardResultView(cardPaymentViewModel: cardPaymentViewModel) + NavigationLink { + CardOrderApproveView(orderID: order.id, cardPaymentViewModel: cardPaymentViewModel) + } label: { + Text("Approve Order with Card") + } + .buttonStyle(RoundedBlueButtonStyle()) + .padding() + } + } + } + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/CreateOrderCardPaymentView.swift b/Demo/Demo/CardPayments/CardPaymentViews/CreateOrderCardPaymentView.swift new file mode 100644 index 000000000..7bc130fa8 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/CreateOrderCardPaymentView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct CreateOrderCardPaymentView: View { + + let selectedMerchantIntegration: MerchantIntegration + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + @State private var selectedIntent: Intent = .authorize + @State private var vaultCustomerID: String = "" + @State var shouldVaultSelected = false + + public init( + cardPaymentViewModel: CardPaymentViewModel, + selectedMerchantIntegration: MerchantIntegration + ) { + self.cardPaymentViewModel = cardPaymentViewModel + self.selectedMerchantIntegration = selectedMerchantIntegration + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Create an Order") + .font(.system(size: 20)) + Spacer() + } + .frame(maxWidth: .infinity) + .font(.headline) + Picker("Intent", selection: $selectedIntent) { + Text("AUTHORIZE").tag(Intent.authorize) + Text("CAPTURE").tag(Intent.capture) + } + .pickerStyle(SegmentedPickerStyle()) + HStack { + Toggle("Should Vault with Purchase", isOn: $shouldVaultSelected) + Spacer() + } + FloatingLabelTextField(placeholder: "Vault Customer ID (Optional)", text: $vaultCustomerID) + ZStack { + Button("Create an Order") { + Task { + do { + cardPaymentViewModel.state.intent = selectedIntent + try await cardPaymentViewModel.createOrder( + amount: "10.00", + selectedMerchantIntegration: DemoSettings.merchantIntegration, + intent: selectedIntent.rawValue, + shouldVault: shouldVaultSelected, + customerID: vaultCustomerID.isEmpty ? nil : vaultCustomerID + ) + } catch { + print("Error in getting setup token. \(error.localizedDescription)") + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if case .loading = cardPaymentViewModel.state.createdOrderResponse { + CircularProgressView() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentViews/OrderCreateCardResultView.swift b/Demo/Demo/CardPayments/CardPaymentViews/OrderCreateCardResultView.swift new file mode 100644 index 000000000..a6ade99bf --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentViews/OrderCreateCardResultView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct OrderCreateCardResultView: View { + + @ObservedObject var cardPaymentViewModel: CardPaymentViewModel + + var body: some View { + switch cardPaymentViewModel.state.createdOrderResponse { + case .idle, .loading: + EmptyView() + case .loaded(let createOrderResponse): + getSuccessView(createOrderResponse: createOrderResponse) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSuccessView(createOrderResponse: Order) -> some View { + VStack(spacing: 16) { + HStack { + Text("Order") + .font(.system(size: 20)) + Spacer() + } + LeadingText("Order ID", weight: .bold) + LeadingText("\(createOrderResponse.id)") + LeadingText("Status", weight: .bold) + LeadingText("\(createOrderResponse.status)") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentState.swift b/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentState.swift new file mode 100644 index 000000000..135cb4624 --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentState.swift @@ -0,0 +1,51 @@ +import Foundation +import CardPayments + +struct CardPaymentState: Equatable { + + struct CardResult: Decodable, Equatable { + + let id: String + let status: String? + let didAttemptThreeDSecureAuthentication: Bool + } + + var createOrder: Order? + var authorizedOrder: Order? + var capturedOrder: Order? + var intent: Intent = .authorize + var scaSelection: SCA = .scaWhenRequired + var approveResult: CardResult? + + var createdOrderResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = createdOrderResponse { + createOrder = value + } + } + } + + var approveResultResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = approveResultResponse { + approveResult = value + } + } + } + + var capturedOrderResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = capturedOrderResponse { + capturedOrder = value + } + } + } + + var authorizedOrderResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = authorizedOrderResponse { + authorizedOrder = value + } + } + } +} diff --git a/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentViewModel.swift b/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentViewModel.swift new file mode 100644 index 000000000..578339eeb --- /dev/null +++ b/Demo/Demo/CardPayments/CardPaymentsViewModel/CardPaymentViewModel.swift @@ -0,0 +1,164 @@ +import Foundation +import CardPayments +import CorePayments +import FraudProtection + +class CardPaymentViewModel: ObservableObject { + + @Published var state = CardPaymentState() + private var payPalDataCollector: PayPalDataCollector? + + let configManager = CoreConfigManager(domain: "Card Payments") + + private var cardClient: CardClient? + + func createOrder( + amount: String, + selectedMerchantIntegration: MerchantIntegration, + intent: String, + shouldVault: Bool, + customerID: String? = nil + ) async throws { + + let amountRequest = Amount(currencyCode: "USD", value: amount) + // TODO: might need to pass in payee as payee object or as auth header + + var vaultCardPaymentSource: VaultCardPaymentSource? + if shouldVault { + var customer: Customer? + if let customerID { + customer = Customer(id: customerID) + } + let attributes = Attributes(vault: Vault(storeInVault: "ON_SUCCESS"), customer: customer) + let card = VaultCard(attributes: attributes) + vaultCardPaymentSource = VaultCardPaymentSource(card: card) + } + + var vaultPaymentSource: VaultPaymentSource? + if let vaultCardPaymentSource { + vaultPaymentSource = .card(vaultCardPaymentSource) + } + + let orderRequestParams = CreateOrderParams( + applicationContext: nil, + intent: intent, + purchaseUnits: [PurchaseUnit(amount: amountRequest)], + paymentSource: vaultPaymentSource + ) + + do { + DispatchQueue.main.async { + self.state.createdOrderResponse = .loading + } + let order = try await DemoMerchantAPI.sharedService.createOrder( + orderParams: orderRequestParams, selectedMerchantIntegration: selectedMerchantIntegration + ) + DispatchQueue.main.async { + self.state.createdOrderResponse = .loaded(order) + print("✅ fetched orderID: \(order.id) with status: \(order.status)") + } + } catch { + DispatchQueue.main.async { + self.state.createdOrderResponse = .error(message: error.localizedDescription) + print("❌ failed to fetch orderID: \(error)") + } + } + } + + func captureOrder(orderID: String, selectedMerchantIntegration: MerchantIntegration) async throws { + do { + DispatchQueue.main.async { + self.state.capturedOrderResponse = .loading + } + let payPalClientMetadataID = payPalDataCollector?.collectDeviceData() + let order = try await DemoMerchantAPI.sharedService.captureOrder( + orderID: orderID, + selectedMerchantIntegration: selectedMerchantIntegration, + payPalClientMetadataID: payPalClientMetadataID + ) + DispatchQueue.main.async { + self.state.capturedOrderResponse = .loaded(order) + } + } catch { + DispatchQueue.main.async { + self.state.capturedOrderResponse = .error(message: error.localizedDescription) + } + print("Error capturing order: \(error.localizedDescription)") + } + } + + func authorizeOrder(orderID: String, selectedMerchantIntegration: MerchantIntegration) async throws { + do { + DispatchQueue.main.async { + self.state.authorizedOrderResponse = .loading + } + let payPalClientMetadataID = payPalDataCollector?.collectDeviceData() + let order = try await DemoMerchantAPI.sharedService.authorizeOrder( + orderID: orderID, + selectedMerchantIntegration: selectedMerchantIntegration, + payPalClientMetadataID: payPalClientMetadataID + ) + DispatchQueue.main.async { + self.state.authorizedOrderResponse = .loaded(order) + } + } catch { + DispatchQueue.main.async { + self.state.authorizedOrderResponse = .error(message: error.localizedDescription) + } + print("Error capturing order: \(error.localizedDescription)") + } + } + + func checkoutWith(card: Card, orderID: String, sca: SCA) async { + do { + DispatchQueue.main.async { + self.state.approveResultResponse = .loading + } + let config = try await configManager.getCoreConfig() + cardClient = CardClient(config: config) + payPalDataCollector = PayPalDataCollector(config: config) + let cardRequest = CardRequest(orderID: orderID, card: card, sca: sca) + cardClient?.approveOrder(request: cardRequest) { result, error in + if let error { + if error == CardError.threeDSecureCanceledError { + self.setApprovalCancelResult() + } else { + self.setApprovalFailureResult(error: error) + } + } else if let result { + self.setApprovalSuccessResult( + approveResult: CardPaymentState.CardResult( + id: result.orderID, + status: result.status, + didAttemptThreeDSecureAuthentication: result.didAttemptThreeDSecureAuthentication + ) + ) + } + } + } catch { + setApprovalFailureResult(error: error) + print("failed in checkout with card. \(error.localizedDescription)") + } + } + + func setApprovalSuccessResult(approveResult: CardPaymentState.CardResult) { + DispatchQueue.main.async { + self.state.approveResultResponse = .loaded( + approveResult + ) + } + } + + func setApprovalFailureResult(error: Error) { + DispatchQueue.main.async { + self.state.approveResultResponse = .error(message: error.localizedDescription) + } + } + + func setApprovalCancelResult() { + print("Canceled") + DispatchQueue.main.async { + self.state.approveResultResponse = .idle + } + } +} diff --git a/Demo/Demo/CardVault/CardVaultViews/CardVaultView.swift b/Demo/Demo/CardVault/CardVaultViews/CardVaultView.swift new file mode 100644 index 000000000..e72529707 --- /dev/null +++ b/Demo/Demo/CardVault/CardVaultViews/CardVaultView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct CardVaultView: View { + + @StateObject var cardVaultViewModel = CardVaultViewModel() + @State var sca: String? = "SCA_WHEN_REQUIRED" + + // MARK: Views + + var body: some View { + ScrollView { + ScrollViewReader { scrollView in + VStack(spacing: 16) { + CreateSetupTokenView( + selectedMerchantIntegration: DemoSettings.merchantIntegration, + vaultViewModel: cardVaultViewModel, + paymentSourceType: PaymentSourceType.card(verification: sca) + ) + SetupTokenResultView(vaultViewModel: cardVaultViewModel) + if let setupToken = cardVaultViewModel.state.setupToken { + UpdateSetupTokenView(cardVaultViewModel: cardVaultViewModel, setupToken: setupToken.id) + } + UpdateSetupTokenResultView(cardVaultViewModel: cardVaultViewModel) + if let updateSetupToken = cardVaultViewModel.state.updateSetupToken { + CreatePaymentTokenView( + vaultViewModel: cardVaultViewModel, + selectedMerchantIntegration: DemoSettings.merchantIntegration, + setupToken: updateSetupToken.id + ) + } + PaymentTokenResultView(vaultViewModel: cardVaultViewModel) + switch cardVaultViewModel.state.paymentTokenResponse { + case .loaded, .error: + VStack { + Button("Reset") { + cardVaultViewModel.resetState() + } + .foregroundColor(.black) + .padding() + .frame(maxWidth: .infinity) + .background(.gray) + .cornerRadius(10) + } + .padding(5) + default: + EmptyView() + } + Text("") + .id("bottomView") + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, 10) + .onChange(of: cardVaultViewModel.state) { _ in + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + } + } +} + +struct CardVault_Previews: PreviewProvider { + + static var previews: some View { + CardVaultView() + } +} diff --git a/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenResultView.swift b/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenResultView.swift new file mode 100644 index 000000000..2e3c926b7 --- /dev/null +++ b/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenResultView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct UpdateSetupTokenResultView: View { + + @ObservedObject var cardVaultViewModel: CardVaultViewModel + + var body: some View { + switch cardVaultViewModel.state.updateSetupTokenResponse { + case .idle, .loading: + EmptyView() + case .loaded(let updateSetupTokenResponse): + getSuccessView(updateSetupTokenResponse: updateSetupTokenResponse) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSuccessView(updateSetupTokenResponse: UpdateSetupTokenResult) -> some View { + VStack(spacing: 16) { + HStack { + Text("Vault Success") + .font(.system(size: 20)) + Spacer() + } + LeadingText("ID", weight: .bold) + LeadingText("\(updateSetupTokenResponse.id)") + if let status = updateSetupTokenResponse.status { + LeadingText("status", weight: .bold) + LeadingText("\(status)") + } + + LeadingText("didAttemptThreeDSecureAuthentication", weight: .bold) + LeadingText("\(updateSetupTokenResponse.didAttemptThreeDSecureAuthentication)") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenView.swift b/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenView.swift new file mode 100644 index 000000000..d2ba5107b --- /dev/null +++ b/Demo/Demo/CardVault/CardVaultViews/UpdateSetupTokenView.swift @@ -0,0 +1,70 @@ +import SwiftUI +import CardPayments +import CorePayments + +struct UpdateSetupTokenView: View { + + let setupToken: String + let cardSections: [CardSection] = [ + // source: https://developer.paypal.com/api/rest/sandbox/card-testing/#link-testcardnumbers + CardSection(title: "Successful Step Up Authentication - Visa", numbers: ["4005 5192 0000 0004"]), + // source: https://developer.paypal.com/api/rest/sandbox/card-testing/#link-testcardnumbers + CardSection(title: "Successful Step Up Authentication, LiabilityShift NO - American Express", numbers: ["371449635398431"]), + CardSection(title: "Failed Step Up Authentication - Matercard", numbers: ["5131 0160 7884 5457"]), + CardSection(title: "Frictionless - LiabilityShift Possible", numbers: ["4005 5192 0000 0004"]), + CardSection(title: "Frictionless - LiabilityShift NO", numbers: ["4020 0278 5185 3235"]), + CardSection(title: "No Challenge", numbers: ["4111 1111 1111 1111"]) + ] + + @State private var cardNumberText: String = "4111 1111 1111 1111" + @State private var expirationDateText: String = "01 / 25" + @State private var cvvText: String = "123" + + @ObservedObject var cardVaultViewModel: CardVaultViewModel + + public init(cardVaultViewModel: CardVaultViewModel, setupToken: String) { + self.cardVaultViewModel = cardVaultViewModel + self.setupToken = setupToken + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Vault Card") + .font(.system(size: 20)) + Spacer() + } + + CardFormView( + cardSections: cardSections, + cardNumberText: $cardNumberText, + expirationDateText: $expirationDateText, + cvvText: $cvvText + ) + + let card = Card.createCard( + cardNumber: cardNumberText, + expirationDate: expirationDateText, + cvv: cvvText + ) + + ZStack { + Button("Vault Card") { + Task { + await cardVaultViewModel.vault(card: card, setupToken: setupToken) + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if case .loading = cardVaultViewModel.state.updateSetupTokenResponse { + CircularProgressView() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/CardVault/CardVaulthViewModel/CardVaultViewModel.swift b/Demo/Demo/CardVault/CardVaulthViewModel/CardVaultViewModel.swift new file mode 100644 index 000000000..6d3a6eccc --- /dev/null +++ b/Demo/Demo/CardVault/CardVaulthViewModel/CardVaultViewModel.swift @@ -0,0 +1,59 @@ +import Foundation +import CardPayments +import CorePayments + +class CardVaultViewModel: VaultViewModel { + + let configManager = CoreConfigManager(domain: "Card Vault") + + func vault(card: Card, setupToken: String) async { + DispatchQueue.main.async { + self.state.updateSetupTokenResponse = .loading + } + do { + let config = try await configManager.getCoreConfig() + let cardClient = CardClient(config: config) + let cardVaultRequest = CardVaultRequest(card: card, setupTokenID: setupToken) + cardClient.vault(cardVaultRequest) { result, error in + if let result { + self.setUpdateSetupTokenResult(vaultResult: result, vaultError: nil) + } else if let error { + self.setUpdateSetupTokenResult(vaultResult: nil, vaultError: error) + } + } + } catch { + self.setUpdateSetupTokenResult(vaultResult: nil, vaultError: error) + print("failed in updating setup token. \(error.localizedDescription)") + } + } + + func isCardFormValid(cardNumber: String, expirationDate: String, cvv: String) -> Bool { + let cleanedCardNumber = cardNumber.replacingOccurrences(of: " ", with: "") + let cleanedExpirationDate = expirationDate.replacingOccurrences(of: " / ", with: "") + + let enabled = cleanedCardNumber.count >= 15 && cleanedCardNumber.count <= 19 + && cleanedExpirationDate.count == 4 && cvv.count >= 3 && cvv.count <= 4 + return enabled + } + + func setUpdateSetupTokenResult(vaultResult: CardVaultResult? = nil, vaultError: Error? = nil) { + DispatchQueue.main.async { + if let vaultResult { + self.state.updateSetupTokenResponse = .loaded( + UpdateSetupTokenResult( + id: vaultResult.setupTokenID, + status: vaultResult.status, + didAttemptThreeDSecureAuthentication: vaultResult.didAttemptThreeDSecureAuthentication + ) + ) + } else if let vaultError { + if let error = vaultError as? CoreSDKError, error == CardError.threeDSecureCanceledError { + print("Canceled") + self.state.updateSetupTokenResponse = .idle + } else { + self.state.updateSetupTokenResponse = .error(message: vaultError.localizedDescription) + } + } + } + } +} diff --git a/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultResultView.swift b/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultResultView.swift new file mode 100644 index 000000000..564fcb519 --- /dev/null +++ b/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultResultView.swift @@ -0,0 +1,41 @@ +import SwiftUI +import PayPalWebPayments + +struct PayPalVaultResultView: View { + + @ObservedObject var viewModel: PayPalVaultViewModel + + var body: some View { + switch viewModel.state.paypalVaultTokenResponse { + case .idle, .loading: + EmptyView() + case .loaded(let vaultResult): + getSuccessView(result: vaultResult) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSuccessView(result: PayPalVaultResult) -> some View { + VStack(spacing: 16) { + HStack { + Text("Vault Success") + .font(.system(size: 20)) + Spacer() + } + LeadingText("ID", weight: .bold) + LeadingText("\(result.tokenID)") + LeadingText("Status", weight: .bold) + LeadingText("APPROVED") + LeadingText("Approval Session ID", weight: .bold) + LeadingText("\(result.approvalSessionID)") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultView.swift b/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultView.swift new file mode 100644 index 000000000..5d0de7e71 --- /dev/null +++ b/Demo/Demo/PayPalVault/PayPalVaultViews/PayPalVaultView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct PayPalVaultView: View { + + @StateObject var paypalVaultViewModel = PayPalVaultViewModel() + + var body: some View { + ScrollView { + ScrollViewReader { scrollView in + VStack(spacing: 16) { + CreateSetupTokenView( + selectedMerchantIntegration: DemoSettings.merchantIntegration, + vaultViewModel: paypalVaultViewModel, + paymentSourceType: PaymentSourceType.paypal(usageType: "MERCHANT") + ) + SetupTokenResultView(vaultViewModel: paypalVaultViewModel) + if let setupTokenID = paypalVaultViewModel.state.setupToken?.id { + ZStack { + Button("Vault PayPal") { + Task { + await paypalVaultViewModel.vault(setupTokenID: setupTokenID) + } + } + .buttonStyle(RoundedBlueButtonStyle()) + .padding() + if case .loading = paypalVaultViewModel.state.paypalVaultTokenResponse { + CircularProgressView() + } + } + } + PayPalVaultResultView(viewModel: paypalVaultViewModel) + if let paypalVaultResult = paypalVaultViewModel.state.paypalVaultToken { + CreatePaymentTokenView( + vaultViewModel: paypalVaultViewModel, + selectedMerchantIntegration: DemoSettings.merchantIntegration, + setupToken: paypalVaultResult.tokenID + ) + } + PaymentTokenResultView(vaultViewModel: paypalVaultViewModel) + switch paypalVaultViewModel.state.paymentTokenResponse { + case .loaded, .error: + VStack { + Button("Reset") { + paypalVaultViewModel.resetState() + } + .foregroundColor(.black) + .padding() + .frame(maxWidth: .infinity) + .background(.gray) + .cornerRadius(10) + } + .padding(5) + default: + EmptyView() + } + Text("") + .id("bottomView") + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, 10) + .onChange(of: paypalVaultViewModel.state) { _ in + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + } + } +} diff --git a/Demo/Demo/PayPalVault/VaultViewModel/PayPalVaultViewModel.swift b/Demo/Demo/PayPalVault/VaultViewModel/PayPalVaultViewModel.swift new file mode 100644 index 000000000..aebf4c650 --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViewModel/PayPalVaultViewModel.swift @@ -0,0 +1,42 @@ +import UIKit +import PayPalWebPayments +import CorePayments + +class PayPalVaultViewModel: VaultViewModel { + + let configManager = CoreConfigManager(domain: "PayPal Vault") + + func vault(setupTokenID: String) async { + DispatchQueue.main.async { + self.state.paypalVaultTokenResponse = .loading + } + do { + let config = try await configManager.getCoreConfig() + let paypalClient = PayPalWebCheckoutClient(config: config) + let vaultRequest = PayPalVaultRequest(setupTokenID: setupTokenID) + paypalClient.vault(vaultRequest) { result, error in + if let error { + if error == PayPalError.vaultCanceledError { + DispatchQueue.main.async { + print("Canceled") + self.state.paypalVaultTokenResponse = .idle + } + } else { + DispatchQueue.main.async { + self.state.paypalVaultTokenResponse = .error(message: error.localizedDescription) + } + } + } else if let result { + DispatchQueue.main.async { + self.state.paypalVaultTokenResponse = .loaded(result) + } + } + } + } catch { + print("Error in vaulting PayPal Payment") + DispatchQueue.main.async { + self.state.paypalVaultTokenResponse = .error(message: error.localizedDescription) + } + } + } +} diff --git a/Demo/Demo/PayPalVault/VaultViewModel/VaultState.swift b/Demo/Demo/PayPalVault/VaultViewModel/VaultState.swift new file mode 100644 index 000000000..eca7a19ae --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViewModel/VaultState.swift @@ -0,0 +1,62 @@ +import Foundation +import CardPayments +import PayPalWebPayments + +struct UpdateSetupTokenResult: Decodable, Equatable { + + var id: String + var status: String? + var didAttemptThreeDSecureAuthentication: Bool +} + +struct VaultState: Equatable { + + var setupToken: CreateSetupTokenResponse? + var paymentToken: PaymentTokenResponse? + // result for card vault + var updateSetupToken: UpdateSetupTokenResult? + // result for paypal vault + var paypalVaultToken: PayPalVaultResult? + + var setupTokenResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = setupTokenResponse { + setupToken = value + } + } + } + + var paymentTokenResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = paymentTokenResponse { + paymentToken = value + } + } + } + + // response from Card Vault + var updateSetupTokenResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = updateSetupTokenResponse { + updateSetupToken = value + } + } + } + + // response from PayPal Vault + var paypalVaultTokenResponse: LoadingState = .idle { + didSet { + if case .loaded(let value) = paypalVaultTokenResponse { + paypalVaultToken = value + } + } + } +} + +enum LoadingState: Equatable { + + case idle + case loading + case error(message: String) + case loaded(_ value: T) +} diff --git a/Demo/Demo/PayPalVault/VaultViewModel/VaultViewModel.swift b/Demo/Demo/PayPalVault/VaultViewModel/VaultViewModel.swift new file mode 100644 index 000000000..3b65c9e65 --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViewModel/VaultViewModel.swift @@ -0,0 +1,58 @@ +import SwiftUI + +class VaultViewModel: ObservableObject { + + @Published var state = VaultState() + + func getSetupToken( + customerID: String? = nil, + selectedMerchantIntegration: MerchantIntegration, + paymentSourceType: PaymentSourceType + ) async throws { + do { + DispatchQueue.main.async { + self.state.setupTokenResponse = .loading + } + let setupTokenResult = try await DemoMerchantAPI.sharedService.createSetupToken( + customerID: customerID, + selectedMerchantIntegration: selectedMerchantIntegration, + paymentSourceType: paymentSourceType + ) + DispatchQueue.main.async { + self.state.setupTokenResponse = .loaded(setupTokenResult) + } + } catch { + DispatchQueue.main.async { + self.state.setupTokenResponse = .error(message: error.localizedDescription) + } + throw error + } + } + + func resetState() { + state = VaultState() + } + + func getPaymentToken( + setupToken: String, + selectedMerchantIntegration: MerchantIntegration + ) async throws { + do { + DispatchQueue.main.async { + self.state.paymentTokenResponse = .loading + } + let paymentTokenResult = try await DemoMerchantAPI.sharedService.createPaymentToken( + setupToken: setupToken, + selectedMerchantIntegration: selectedMerchantIntegration + ) + DispatchQueue.main.async { + self.state.paymentTokenResponse = .loaded(paymentTokenResult) + } + } catch { + DispatchQueue.main.async { + self.state.paymentTokenResponse = .error(message: error.localizedDescription) + } + throw error + } + } +} diff --git a/Demo/Demo/PayPalVault/VaultViews/CreatePaymentTokenView.swift b/Demo/Demo/PayPalVault/VaultViews/CreatePaymentTokenView.swift new file mode 100644 index 000000000..ded38c87a --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViews/CreatePaymentTokenView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct CreatePaymentTokenView: View { + + let selectedMerchantIntegration: MerchantIntegration + let setupToken: String + + @ObservedObject var vaultViewModel: VaultViewModel + + public init(vaultViewModel: VaultViewModel, selectedMerchantIntegration: MerchantIntegration, setupToken: String) { + self.vaultViewModel = vaultViewModel + self.selectedMerchantIntegration = selectedMerchantIntegration + self.setupToken = setupToken + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Create a Payment Method Token") + .font(.system(size: 20)) + Spacer() + } + .frame(maxWidth: .infinity) + .font(.headline) + ZStack { + Button("Create Payment Token") { + Task { + do { + try await vaultViewModel.getPaymentToken( + setupToken: setupToken, + selectedMerchantIntegration: selectedMerchantIntegration + ) + } catch { + print("Error in getting payment token. \(error.localizedDescription)") + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if case .loading = vaultViewModel.state.paymentTokenResponse { + CircularProgressView() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalVault/VaultViews/CreateSetupTokenView.swift b/Demo/Demo/PayPalVault/VaultViews/CreateSetupTokenView.swift new file mode 100644 index 000000000..7598bbfab --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViews/CreateSetupTokenView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct CreateSetupTokenView: View { + + let selectedMerchantIntegration: MerchantIntegration + + @State private var vaultCustomerID: String = "" + @State private var sca: String = "SCA_WHEN_REQUIRED" + @State var paymentSourceType: PaymentSourceType + + @ObservedObject var vaultViewModel: VaultViewModel + + public init(selectedMerchantIntegration: MerchantIntegration, vaultViewModel: VaultViewModel, paymentSourceType: PaymentSourceType) { + self.selectedMerchantIntegration = selectedMerchantIntegration + self.vaultViewModel = vaultViewModel + self.paymentSourceType = paymentSourceType + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Vault without Purchase requires creation of setup token:") + .font(.system(size: 20)) + Spacer() + } + .frame(maxWidth: .infinity) + .font(.headline) + FloatingLabelTextField(placeholder: "Vault Customer ID (Optional)", text: $vaultCustomerID) + if case .card = paymentSourceType { + Picker("SCA", selection: $sca) { + Text("SCA_WHEN_REQUIRED").tag("SCA_WHEN_REQUIRED") + Text("SCA_ALWAYS").tag("SCA_ALWAYS") + } + .pickerStyle(SegmentedPickerStyle()) + .frame(height: 50) + } + + ZStack { + Button("Create Setup Token") { + Task { + do { + try await vaultViewModel.getSetupToken( + customerID: vaultCustomerID.isEmpty ? nil : vaultCustomerID, + selectedMerchantIntegration: selectedMerchantIntegration, + paymentSourceType: paymentSourceType + ) + } catch { + print("Error in getting setup token. \(error.localizedDescription)") + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if case .loading = vaultViewModel.state.setupTokenResponse { + CircularProgressView() + } + } + } + .onChange(of: sca) { _ in + paymentSourceType = PaymentSourceType.card(verification: sca) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalVault/VaultViews/PaymentTokenResultView.swift b/Demo/Demo/PayPalVault/VaultViews/PaymentTokenResultView.swift new file mode 100644 index 000000000..24c7e1bfc --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViews/PaymentTokenResultView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct PaymentTokenResultView: View { + + @ObservedObject var vaultViewModel: VaultViewModel + + var body: some View { + switch vaultViewModel.state.paymentTokenResponse { + case .idle, .loading: + EmptyView() + case .loaded(let paymentTokenResponse): + getSucessView(paymentTokenResponse: paymentTokenResponse) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSucessView(paymentTokenResponse: PaymentTokenResponse) -> some View { + VStack(spacing: 16) { + HStack { + Text("Payment Token") + .font(.system(size: 20)) + Spacer() + } + LeadingText("ID", weight: .bold) + LeadingText("\(paymentTokenResponse.id)") + LeadingText("Customer ID", weight: .bold) + LeadingText("\(paymentTokenResponse.customer.id)") + if let card = paymentTokenResponse.paymentSource.card { + LeadingText("Card Brand", weight: .bold) + LeadingText("\(card.brand ?? "")") + LeadingText("Card Last 4", weight: .bold) + LeadingText("\(card.lastDigits)") + } else if let paypal = paymentTokenResponse.paymentSource.paypal { + LeadingText("Email", weight: .bold) + LeadingText("\(paypal.emailAddress)") + } + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalVault/VaultViews/SetupTokenResultView.swift b/Demo/Demo/PayPalVault/VaultViews/SetupTokenResultView.swift new file mode 100644 index 000000000..93f21726d --- /dev/null +++ b/Demo/Demo/PayPalVault/VaultViews/SetupTokenResultView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SetupTokenResultView: View { + + @ObservedObject var vaultViewModel: VaultViewModel + + var body: some View { + switch vaultViewModel.state.setupTokenResponse { + case .idle, .loading: + EmptyView() + case .loaded(let setupTokenResponse): + getSuccessView(setupTokenResponse: setupTokenResponse) + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + func getSuccessView(setupTokenResponse: CreateSetupTokenResponse) -> some View { + VStack(spacing: 16) { + HStack { + Text("Setup Token") + .font(.system(size: 20)) + Spacer() + } + LeadingText("ID", weight: .bold) + LeadingText("\(setupTokenResponse.id)") + LeadingText("Customer ID", weight: .bold) + LeadingText("\(setupTokenResponse.customer?.id ?? "")") + LeadingText("Status", weight: .bold) + LeadingText("\(setupTokenResponse.status)") + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift new file mode 100644 index 000000000..03ad1a02b --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebButtonsView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import PaymentButtons +import PayPalWebPayments + +struct PayPalWebButtonsView: View { + + @ObservedObject var payPalWebViewModel: PayPalWebViewModel + + @State private var selectedFundingSource: PayPalWebCheckoutFundingSource = .paypal + + var body: some View { + VStack { + VStack(alignment: .center, spacing: 16) { + HStack { + Text("Checkout with PayPal") + .font(.system(size: 20)) + Spacer() + } + .frame(maxWidth: .infinity) + .font(.headline) + Picker("Funding Source", selection: $selectedFundingSource) { + Text("PayPal").tag(PayPalWebCheckoutFundingSource.paypal) + Text("PayPal Credit").tag(PayPalWebCheckoutFundingSource.paypalCredit) + Text("Pay Later").tag(PayPalWebCheckoutFundingSource.paylater) + } + .pickerStyle(SegmentedPickerStyle()) + ZStack { + switch selectedFundingSource { + case .paypalCredit: + PayPalCreditButton.Representable(color: .black, size: .full) { + payPalWebViewModel.paymentButtonTapped(funding: .paypalCredit) + } + case .paylater: + PayPalPayLaterButton.Representable(color: .silver, edges: .softEdges, size: .full) { + payPalWebViewModel.paymentButtonTapped(funding: .paylater) + } + case .paypal: + PayPalButton.Representable(color: .blue, size: .full) { + payPalWebViewModel.paymentButtonTapped(funding: .paypal) + } + } + if payPalWebViewModel.state == .loading && + payPalWebViewModel.checkoutResult == nil && + payPalWebViewModel.orderID != nil { + CircularProgressView() + } + } + } + .frame(height: 150) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebCreateOrderView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebCreateOrderView.swift new file mode 100644 index 000000000..4b5adc2dd --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebCreateOrderView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct PayPalWebCreateOrderView: View { + + @ObservedObject var payPalWebViewModel: PayPalWebViewModel + + @State private var selectedIntent: Intent = .authorize + @State var shouldVaultSelected = false + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Create an Order") + .font(.system(size: 20)) + Spacer() + Button("Reset") { + payPalWebViewModel.resetState() + } + } + .frame(maxWidth: .infinity) + .font(.headline) + Picker("Intent", selection: $selectedIntent) { + Text("AUTHORIZE").tag(Intent.authorize) + Text("CAPTURE").tag(Intent.capture) + } + .pickerStyle(SegmentedPickerStyle()) + HStack { + Toggle("Should Vault with Purchase", isOn: $shouldVaultSelected) + Spacer() + } + ZStack { + Button("Create an Order") { + Task { + do { + payPalWebViewModel.intent = selectedIntent + try await payPalWebViewModel.createOrder(shouldVault: shouldVaultSelected) + } catch { + print("Error in getting setup token. \(error.localizedDescription)") + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + if payPalWebViewModel.state == .loading && payPalWebViewModel.checkoutResult == nil && payPalWebViewModel.orderID == nil { + CircularProgressView() + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebPaymentsView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebPaymentsView.swift new file mode 100644 index 000000000..3b47ac991 --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebPaymentsView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct PayPalWebPaymentsView: View { + + @StateObject var payPalWebViewModel = PayPalWebViewModel() + + var body: some View { + ScrollView { + ScrollViewReader { scrollView in + VStack(spacing: 16) { + PayPalWebCreateOrderView(payPalWebViewModel: payPalWebViewModel) + + if payPalWebViewModel.orderID != nil { + PayPalWebButtonsView(payPalWebViewModel: payPalWebViewModel) + } + + PayPalWebResultView(payPalWebViewModel: payPalWebViewModel) + + if payPalWebViewModel.checkoutResult != nil { + PayPalWebTransactionView(payPalWebViewModel: payPalWebViewModel) + .padding(.bottom, 20) + .id("bottomView") + .onAppear { + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + .onChange(of: payPalWebViewModel.state) { _ in + withAnimation { + scrollView.scrollTo("bottomView") + } + } + } + } + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebResultView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebResultView.swift new file mode 100644 index 000000000..e1d9f744a --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebResultView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct PayPalWebResultView: View { + + @ObservedObject var payPalWebViewModel: PayPalWebViewModel + + var body: some View { + switch payPalWebViewModel.state { + case .idle, .loading: + EmptyView() + case .success: + successView + case .error(let errorMessage): + ErrorView(errorMessage: errorMessage) + } + } + + var successView: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Order Details") + .font(.system(size: 20)) + Spacer() + } + if let orderID = payPalWebViewModel.orderID { + LabelViewText("Order ID:", bodyText: orderID) + } + + if let status = payPalWebViewModel.order?.status { + LabelViewText("Status:", bodyText: status) + } + + if let payerID = payPalWebViewModel.checkoutResult?.payerID { + LabelViewText("Payer ID:", bodyText: payerID) + } + + if let emailAddress = payPalWebViewModel.order?.paymentSource?.paypal?.emailAddress { + LabelViewText("Email:", bodyText: emailAddress) + } + + if let vaultID = payPalWebViewModel.order?.paymentSource?.paypal?.attributes?.vault.id { + LabelViewText("Payment Token:", bodyText: vaultID) + } + + if let customerID = payPalWebViewModel.order?.paymentSource?.paypal?.attributes?.vault.customer?.id { + LabelViewText("Customer ID:", bodyText: customerID) + } + } + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(.gray, lineWidth: 2) + .padding(5) + ) + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebTransactionView.swift b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebTransactionView.swift new file mode 100644 index 000000000..e8b0a132e --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebPaymentsView/PayPalWebTransactionView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct PayPalWebTransactionView: View { + + @ObservedObject var payPalWebViewModel: PayPalWebViewModel + + var body: some View { + VStack { + ZStack { + Button("\(payPalWebViewModel.intent.rawValue.capitalized) Order") { + Task { + do { + try await payPalWebViewModel.completeTransaction() + } catch { + print("Error capturing order: \(error.localizedDescription)") + } + } + } + .buttonStyle(RoundedBlueButtonStyle()) + .padding() + + if payPalWebViewModel.state == .loading { + CircularProgressView() + } + } + } + } +} diff --git a/Demo/Demo/PayPalWebPayments/PayPalWebViewModel/PayPalWebViewModel.swift b/Demo/Demo/PayPalWebPayments/PayPalWebViewModel/PayPalWebViewModel.swift new file mode 100644 index 000000000..0381d4338 --- /dev/null +++ b/Demo/Demo/PayPalWebPayments/PayPalWebViewModel/PayPalWebViewModel.swift @@ -0,0 +1,146 @@ +import Foundation +import CorePayments +import PayPalWebPayments +import FraudProtection + +class PayPalWebViewModel: ObservableObject { + + @Published var state: CurrentState = .idle + @Published var intent: Intent = .authorize + @Published var order: Order? + @Published var checkoutResult: PayPalWebCheckoutResult? + + var payPalWebCheckoutClient: PayPalWebCheckoutClient? + + var orderID: String? { + order?.id + } + + let configManager = CoreConfigManager(domain: "PayPalWeb Payments") + private var payPalDataCollector: PayPalDataCollector? + + func createOrder(shouldVault: Bool) async throws { + let amountRequest = Amount(currencyCode: "USD", value: "10.00") + + // TODO: might need to pass in payee as payee object or as auth header + var vaultPayPalPaymentSource: VaultPayPalPaymentSource? + if shouldVault { + let attributes = Attributes(vault: Vault(storeInVault: "ON_SUCCESS", usageType: "MERCHANT", customerType: "CONSUMER")) + // The returnURL is not used in our mobile SDK, but a required field for create order with PayPal payment source. DTPPCPSDK-1492 to track this issue + let paypal = VaultPayPal(attributes: attributes, experienceContext: ExperienceContext(returnURL: "https://example.com/returnUrl", cancelURL: "https://example.com/cancelUrl")) + vaultPayPalPaymentSource = VaultPayPalPaymentSource(paypal: paypal) + } + + var vaultPaymentSource: VaultPaymentSource? + if let vaultPayPalPaymentSource { + vaultPaymentSource = .paypal(vaultPayPalPaymentSource) + } + + let orderRequestParams = CreateOrderParams( + applicationContext: nil, + intent: intent.rawValue, + purchaseUnits: [PurchaseUnit(amount: amountRequest)], + paymentSource: vaultPaymentSource + ) + + do { + updateState(.loading) + let order = try await DemoMerchantAPI.sharedService.createOrder( + orderParams: orderRequestParams, + selectedMerchantIntegration: DemoSettings.merchantIntegration + ) + + updateOrder(order) + updateState(.success) + print("✅ fetched orderID: \(order.id) with status: \(order.status)") + } catch { + updateState(.error(message: error.localizedDescription)) + print("❌ failed to fetch orderID with error: \(error.localizedDescription)") + } + } + + func paymentButtonTapped(funding: PayPalWebCheckoutFundingSource) { + Task { + do { + self.updateState(.loading) + payPalWebCheckoutClient = try await getPayPalClient() + guard let payPalWebCheckoutClient else { + print("Error initializing PayPalWebCheckoutClient") + return + } + + if let orderID { + let payPalRequest = PayPalWebCheckoutRequest(orderID: orderID, fundingSource: funding) + payPalWebCheckoutClient.start(request: payPalRequest) { result, error in + if let error { + if error == PayPalError.checkoutCanceledError { + print("Canceled") + self.updateState(.idle) + } else { + self.updateState(.error(message: error.localizedDescription)) + } + } else { + self.updateState(.success) + self.checkoutResult = result + } + } + } + updateState(.success) + } catch { + print("Error starting PayPalWebCheckoutClient") + updateState(.error(message: error.localizedDescription)) + } + } + } + + func getPayPalClient() async throws -> PayPalWebCheckoutClient? { + do { + let config = try await configManager.getCoreConfig() + let payPalClient = PayPalWebCheckoutClient(config: config) + payPalDataCollector = PayPalDataCollector(config: config) + return payPalClient + } catch { + updateState(.error(message: error.localizedDescription)) + print("❌ failed to create PayPalWebCheckoutClient with error: \(error.localizedDescription)") + return nil + } + } + + func completeTransaction() async throws { + do { + updateState(.loading) + + let payPalClientMetadataID = payPalDataCollector?.collectDeviceData() + if let orderID { + let order = try await DemoMerchantAPI.sharedService.completeOrder( + intent: intent, + orderID: orderID, + payPalClientMetadataID: payPalClientMetadataID + ) + updateOrder(order) + updateState(.success) + } + } catch { + updateState(.error(message: error.localizedDescription)) + print("Error with \(intent) order: \(error.localizedDescription)") + } + } + + func resetState() { + updateState(.idle) + order = nil + checkoutResult = nil + } + + private func updateOrder(_ order: Order) { + DispatchQueue.main.async { + self.order = order + } + } + + private func updateState(_ state: CurrentState) { + DispatchQueue.main.async { + self.state = state + } + } +}