diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..f4ed7ca701 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.strings diff diff --git a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj index 408ff55fb4..7f2d712aef 100644 --- a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj +++ b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ A1536BAD2AEBEC3A0087DDC0 /* NolPayPhoneMetadataServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1536BAC2AEBEC3A0087DDC0 /* NolPayPhoneMetadataServiceTests.swift */; }; A1536BAF2AEC0A6D0087DDC0 /* NolTestsMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1536BAE2AEC0A6D0087DDC0 /* NolTestsMocks.swift */; }; A1585C752ACDAA700014F0B9 /* NolPayLinkedCardsComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1585C742ACDAA700014F0B9 /* NolPayLinkedCardsComponentTests.swift */; }; + A19CB17B2BAA129900DB4326 /* CVVRecaptureViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19CB17A2BAA129900DB4326 /* CVVRecaptureViewModelTests.swift */; }; A19EF5632B20E22E00A72F60 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = A19EF5622B20E22E00A72F60 /* .swiftlint.yml */; }; A1A3D0F32AD5585A00F7D8C9 /* NolPayUnlinkCardComponentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A3D0F22AD5585A00F7D8C9 /* NolPayUnlinkCardComponentTest.swift */; }; A1A3D0F52AD56BE300F7D8C9 /* NolPayPaymentComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A3D0F42AD56BE300F7D8C9 /* NolPayPaymentComponentTests.swift */; }; @@ -129,7 +130,7 @@ E6F85ECD80B64754E7A6D35E /* RawDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7C082270CF1C6B7810F9B3 /* RawDataManagerTests.swift */; }; EA7FAA4F8476BD3711D628CB /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B18D7E7738BF86467B0F1465 /* Images.xcassets */; }; F02F496FD20B5291C044F62C /* MerchantResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E3C8FE62D22147335F2455 /* MerchantResultViewController.swift */; }; - F03699592AC2E63700E4179D /* BuildFile in Sources */ = {isa = PBXBuildFile; }; + F03699592AC2E63700E4179D /* (null) in Sources */ = {isa = PBXBuildFile; }; F08F63D82B9B5A7C006EF9A9 /* SessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63D72B9B5A7C006EF9A9 /* SessionConfiguration.swift */; }; F08F63DA2B9B5BC5006EF9A9 /* AppetizeConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63D92B9B5BC5006EF9A9 /* AppetizeConfigProvider.swift */; }; F08F63DC2B9F27B0006EF9A9 /* MetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63DB2B9F27B0006EF9A9 /* MetadataParser.swift */; }; @@ -284,6 +285,7 @@ A1536BAE2AEC0A6D0087DDC0 /* NolTestsMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NolTestsMocks.swift; sourceTree = ""; }; A1585C742ACDAA700014F0B9 /* NolPayLinkedCardsComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NolPayLinkedCardsComponentTests.swift; sourceTree = ""; }; A1604A656AF654D7422A2A5E /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; + A19CB17A2BAA129900DB4326 /* CVVRecaptureViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVVRecaptureViewModelTests.swift; sourceTree = ""; }; A19EF5622B20E22E00A72F60 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; A1A3D0F22AD5585A00F7D8C9 /* NolPayUnlinkCardComponentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NolPayUnlinkCardComponentTest.swift; sourceTree = ""; }; A1A3D0F42AD56BE300F7D8C9 /* NolPayPaymentComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NolPayPaymentComponentTests.swift; sourceTree = ""; }; @@ -509,6 +511,7 @@ EEB1E1B37192BF739461AFF1 /* PrimerRawCardDataManagerTests.swift */, B266F9E1651BD20E45DCCF68 /* PrimerRawRetailerDataTests.swift */, 049A055F2B4C191D002CEEBA /* NativeUIManagerTests.swift */, + A19CB17A2BAA129900DB4326 /* CVVRecaptureViewModelTests.swift */, ); path = Primer; sourceTree = ""; @@ -1091,6 +1094,7 @@ 3BB02CA24B6B3EF458326B7D /* Networking.swift in Sources */, 25FA73D4BBA89962663B5378 /* Mocks.swift in Sources */, 2E0D85B7343377F1319902AD /* MockPaymentMethodTokenizationViewModel.swift in Sources */, + A19CB17B2BAA129900DB4326 /* CVVRecaptureViewModelTests.swift in Sources */, E11F473D2B0694C50091C31F /* PrimerHeadlessFormWithRedirectManagerTests.swift in Sources */, 161D4BE3FFD5E4A60F4461F0 /* CreateResumePaymentService.swift in Sources */, C6D7F7ECFD35B3DC3AFD6CB2 /* PayPalService.swift in Sources */, @@ -1099,7 +1103,7 @@ 583EBAA90902121CEA479416 /* VaultService.swift in Sources */, 961B5D18058EF4CFCD0185AE /* MockVaultCheckoutViewModel.swift in Sources */, 622A605DDEA98D981670B53F /* DropInUI_TokenizationViewModelTests.swift in Sources */, - F03699592AC2E63700E4179D /* BuildFile in Sources */, + F03699592AC2E63700E4179D /* (null) in Sources */, 208CA849F3187C2DA63CC17B /* HUC_TokenizationViewModelTests.swift in Sources */, 213196DEDF2A3A84037ED884 /* PollingModuleTests.swift in Sources */, 04F6EF742AE6A06200115D05 /* AnalyticsEventsTests.swift in Sources */, diff --git a/Debug App/Primer.io Debug App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Debug App/Primer.io Debug App.xcworkspace/xcshareddata/swiftpm/Package.resolved index 32b776b34d..b63f4f1178 100644 --- a/Debug App/Primer.io Debug App.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Debug App/Primer.io Debug App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/primer-io/primer-klarna-sdk-ios", "state": { "branch": null, - "revision": "146d9ae8f7accc1ad7b64b2a455106d54914edef", - "version": "1.1.0" + "revision": "f13260c24a900f28e21bafd213c22191e6280e86", + "version": "1.0.4" } }, { diff --git a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard index 554b1f7039..56f7ae8ea6 100644 --- a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard +++ b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard @@ -177,7 +177,7 @@ - + @@ -380,7 +380,7 @@ - + + + + + + + + + + - + - + - + @@ -1154,6 +1168,7 @@ + diff --git a/Debug App/Sources/Model/CreateClientToken.swift b/Debug App/Sources/Model/CreateClientToken.swift index 1a3a2912e7..05373d27e9 100644 --- a/Debug App/Sources/Model/CreateClientToken.swift +++ b/Debug App/Sources/Model/CreateClientToken.swift @@ -320,7 +320,7 @@ struct ClientSessionRequestBody { struct PaymentMethod: Codable { let vaultOnSuccess: Bool? - let options: PaymentMethodOptionGroup? + var options: PaymentMethodOptionGroup? let descriptor: String? let paymentType: String? @@ -348,6 +348,7 @@ struct ClientSessionRequestBody { struct PaymentMethodOptionGroup: Codable { var KLARNA: PaymentMethodOption? + var PAYMENT_CARD: PaymentMethodOption? var dictionaryValue: [String: Any]? { var dic: [String: Any] = [:] @@ -356,6 +357,10 @@ struct ClientSessionRequestBody { dic["KLARNA"] = KLARNA.dictionaryValue } + if let PAYMENT_CARD = PAYMENT_CARD { + dic["PAYMENT_CARD"] = PAYMENT_CARD.dictionaryValue + } + return dic.keys.count == 0 ? nil : dic } } @@ -364,15 +369,20 @@ struct ClientSessionRequestBody { var surcharge: SurchargeOption? var instalmentDuration: String? var extraMerchantData: [String: Any]? + var captureVaultedCardCvv: Bool? enum CodingKeys: CodingKey { - case surcharge, instalmentDuration, extraMerchantData + case surcharge, instalmentDuration, extraMerchantData, captureVaultedCardCvv } - - init(surcharge: SurchargeOption?, instalmentDuration: String?, extraMerchantData: [String: Any]?) { + + init(surcharge: SurchargeOption?, + instalmentDuration: String?, + extraMerchantData: [String: Any]?, + captureVaultedCardCvv: Bool?) { self.surcharge = surcharge self.instalmentDuration = instalmentDuration self.extraMerchantData = extraMerchantData + self.captureVaultedCardCvv = captureVaultedCardCvv } func encode(to encoder: Encoder) throws { @@ -404,6 +414,8 @@ struct ClientSessionRequestBody { } else { extraMerchantData = nil } + + captureVaultedCardCvv = try container.decodeIfPresent(Bool.self, forKey: .captureVaultedCardCvv) ?? false } var dictionaryValue: [String: Any]? { @@ -421,6 +433,10 @@ struct ClientSessionRequestBody { dic["extraMerchantData"] = extraMerchantData } + if let captureVaultedCardCvv = captureVaultedCardCvv { + dic["captureVaultedCardCvv"] = captureVaultedCardCvv + } + return dic.keys.count == 0 ? nil : dic } } diff --git a/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift b/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift index eb5d8b4d3f..127535634f 100644 --- a/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift +++ b/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift @@ -87,16 +87,17 @@ struct MerchantMockDataManager { static var klarnaPaymentMethod = ClientSessionRequestBody.PaymentMethod( vaultOnSuccess: nil, - options: paymentOptions, + options: klarnaPaymentOptions, descriptor: "test-descriptor", paymentType: nil ) - - static var paymentOptions = ClientSessionRequestBody.PaymentMethod.PaymentMethodOptionGroup( + + static var klarnaPaymentOptions = ClientSessionRequestBody.PaymentMethod.PaymentMethodOptionGroup( KLARNA: ClientSessionRequestBody.PaymentMethod.PaymentMethodOption( surcharge: ClientSessionRequestBody.PaymentMethod.SurchargeOption(amount: 140), instalmentDuration: "test", - extraMerchantData: extraMerchantData)) + extraMerchantData: extraMerchantData, + captureVaultedCardCvv: false)) static var extraMerchantData: [String: Any] = [ "subscription": [ @@ -115,5 +116,4 @@ struct MerchantMockDataManager { ] ] ] - } diff --git a/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift b/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift index 1a444a5300..3764447d3c 100644 --- a/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift @@ -69,7 +69,8 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBOutlet weak var disableSuccessScreenSwitch: UISwitch! @IBOutlet weak var disableErrorScreenSwitch: UISwitch! @IBOutlet weak var disableInitScreenSwitch: UISwitch! - + @IBOutlet weak var enableCVVRecaptureFlowSwitch: UISwitch! + // MARK: Order Inputs @IBOutlet weak var currencyTextField: UITextField! @@ -416,7 +417,15 @@ class MerchantSessionAndSettingsViewController: UIViewController { } clientSession.paymentMethod = MerchantMockDataManager.getPaymentMethod(sessionType: paymentSessionType) - + if paymentSessionType == .generic && enableCVVRecaptureFlowSwitch.isOn { + let option = ClientSessionRequestBody.PaymentMethod.PaymentMethodOption(surcharge: nil, + instalmentDuration: nil, + extraMerchantData: nil, + captureVaultedCardCvv: enableCVVRecaptureFlowSwitch.isOn) + + let optionGroup = ClientSessionRequestBody.PaymentMethod.PaymentMethodOptionGroup(PAYMENT_CARD: option) + clientSession.paymentMethod?.options = optionGroup + } if let metadata = metadataTextField.text, !metadata.isEmpty { clientSession.metadata = MetadataParser().parse(metadata) } @@ -425,6 +434,8 @@ class MerchantSessionAndSettingsViewController: UIViewController { func populateSessionSettingsFields() { clientSession = MerchantMockDataManager.getClientSession(sessionType: paymentSessionType) + enableCVVRecaptureFlowSwitch.isOn = clientSession.paymentMethod?.options?.PAYMENT_CARD?.captureVaultedCardCvv == true + currencyTextField.text = clientSession.currencyCode?.code countryCodeTextField.text = clientSession.order?.countryCode?.rawValue orderIdTextField.text = clientSession.orderId diff --git a/Debug App/Tests/Unit Tests/Mocks.swift b/Debug App/Tests/Unit Tests/Mocks.swift index a8009c2d6d..f8c28e5b8a 100644 --- a/Debug App/Tests/Unit Tests/Mocks.swift +++ b/Debug App/Tests/Unit Tests/Mocks.swift @@ -216,7 +216,7 @@ class MockPrimerDelegate: PrimerDelegate { "class": "\(Self.self)", "function": #function, "line": "\(#line)"], - diagnosticsId: UUID().uuidString)) + diagnosticsId: UUID().uuidString)) return } completion(token, nil) diff --git a/Debug App/Tests/Unit Tests/Primer/CVVRecaptureViewModelTests.swift b/Debug App/Tests/Unit Tests/Primer/CVVRecaptureViewModelTests.swift new file mode 100644 index 0000000000..96b9e73927 --- /dev/null +++ b/Debug App/Tests/Unit Tests/Primer/CVVRecaptureViewModelTests.swift @@ -0,0 +1,123 @@ +// +// CVVRecaptureViewModelTests.swift +// Debug App Tests +// +// Created by Boris on 19.3.24.. +// Copyright © 2024 Primer API Ltd. All rights reserved. +// + +import XCTest +@testable import PrimerSDK + +// Mock Classes +struct MockCardButtonViewModel: CardButtonViewModelProtocol { + var cardholder: String + + var last4: String + + var expiry: String + + var imageName: PrimerSDK.ImageName + + var paymentMethodType: PrimerSDK.PaymentInstrumentType + + var surCharge: Int? + + var network: String +} + +// Unit Tests for CVVRecaptureViewModel +class CVVRecaptureViewModelTests: XCTestCase { + + var viewModel: CVVRecaptureViewModel! + var mockCardButtonViewModel: MockCardButtonViewModel! + + override func setUp() { + super.setUp() + mockCardButtonViewModel = MockCardButtonViewModel(cardholder: "John Doe", + last4: "4444", + expiry: "13/05", + imageName: ImageName.genericCard, + paymentMethodType: PaymentInstrumentType.paymentCard, + network: "VISA") + viewModel = CVVRecaptureViewModel() + viewModel.cardButtonViewModel = mockCardButtonViewModel + } + + override func tearDown() { + viewModel = nil + mockCardButtonViewModel = nil + super.tearDown() + } + + // Test for CVV Length Calculation + func testCvvLengthForKnownNetwork() { + // Setup for a known network with specific CVV length + // e.g., mockCardButtonViewModel.network = "Visa" + let expectedLength = 3 // Adjust based on expected CVV length for the mocked network + XCTAssertEqual(viewModel.cvvLength, expectedLength, "CVV length should match the expected value for the given network") + } + + // Test Continue Button State Change on isValidCvv Update + func testContinueButtonStateChangeOnIsValidCvvUpdate() { + let expectation = XCTestExpectation(description: "onContinueButtonStateChange closure is called") + + viewModel.onContinueButtonStateChange = { isEnabled in + XCTAssertTrue(isEnabled, "Continue button should be enabled when isValidCvv is true") + expectation.fulfill() + } + + viewModel.isValidCvv = true + + wait(for: [expectation], timeout: 1.0) + } + + // Test Continue Button Tapped Action with Valid CVV + func testContinueButtonTappedWithValidCvv() { + let cvv = "123" + viewModel.isValidCvv = true + + let expectation = XCTestExpectation(description: "didSubmitCvv closure is called") + + viewModel.didSubmitCvv = { submittedCvv in + XCTAssertEqual(submittedCvv, cvv, "Submitted CVV should match the input CVV") + expectation.fulfill() + } + + viewModel.continueButtonTapped(with: cvv) + + wait(for: [expectation], timeout: 1.0) + } + + // Test Continue Button Tapped Action with Invalid CVV + func testContinueButtonTappedWithInvalidCvv() { + let cvv = "123" + viewModel.isValidCvv = false // Simulate an invalid CVV + + var didCallSubmitCvv = false + viewModel.didSubmitCvv = { _ in + didCallSubmitCvv = true + } + + viewModel.continueButtonTapped(with: cvv) + + XCTAssertFalse(didCallSubmitCvv, "didSubmitCvv should not be called when CVV is invalid") + } + + // Test for invalid CVV inputs + func testContinueButtonTappedWithInvalidCvvInputs() { + let invalidCvvs = ["", "abc", "1234"] // Examples of invalid CVVs + + for cvv in invalidCvvs { + var didCallSubmitCvv = false + viewModel.didSubmitCvv = { _ in + didCallSubmitCvv = true + } + + viewModel.continueButtonTapped(with: cvv) + + // Check that didSubmitCvv was not called for invalid CVV inputs + XCTAssertFalse(didCallSubmitCvv, "didSubmitCvv should not be called for invalid CVV input: \(cvv)") + } + } +} diff --git a/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift b/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift index bf3779d6ca..e1725f7233 100644 --- a/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift +++ b/Sources/PrimerSDK/Classes/Core/Constants/Strings.swift @@ -936,6 +936,29 @@ extension Strings { comment: "An error message displayed when the Last Name is not correct") } } + + struct CVVRecapture { + static let title = NSLocalizedString( + "primer-cvv-recapture-title", + tableName: nil, + bundle: Bundle.primerResources, + value: "Enter CVV", + comment: "Enter CVV - CVV recapture screen title") + + static let explanation = NSLocalizedString( + "primer-cvv-recapture-explanation", + tableName: nil, + bundle: Bundle.primerResources, + value: "Input the %d digit security code on your card for a secure payment.", + comment: "Some cards have 3 or 4 digits for their CVV card") + + static let buttonTitle = NSLocalizedString( + "continue", + tableName: nil, + bundle: Bundle.primerResources, + value: "Continue", + comment: "Continue") + } } // MARK: - Apple Pay diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerHeadlessUniversalCheckoutPaymentMethod.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerHeadlessUniversalCheckoutPaymentMethod.swift index 4ac2483f86..cf4a513f3f 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerHeadlessUniversalCheckoutPaymentMethod.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Models/PrimerHeadlessUniversalCheckoutPaymentMethod.swift @@ -5,6 +5,8 @@ // Created by Evangelos on 27/9/22. // +// swiftlint:disable nesting + import Foundation import PassKit @@ -12,6 +14,17 @@ extension PrimerHeadlessUniversalCheckout { public class PaymentMethod: NSObject { + /// To enhance your experience and provide consistent functionality across our services, + /// some features are available in both our Headless and Drop-In interfaces. + /// While Drop-In automatically utilizes these features through predefined settings, + /// the Headless interface offers you the flexibility to enable these features manually. + /// This is made possible through the introduction of the "PrimerAvailablePaymentMethodsOptions". + /// This option allows you to customize your Headless setup by enabling specific features + /// that align with the capabilities available in the Drop-In interface. + public struct PrimerAvailablePaymentMethodsOptions { + let captureVaultedCardCvv: Bool? + } + static var availablePaymentMethods: [PrimerHeadlessUniversalCheckout.PaymentMethod] { var availablePaymentMethods = PrimerAPIConfiguration.paymentMethodConfigs? .compactMap({ $0.type }) @@ -33,6 +46,7 @@ extension PrimerHeadlessUniversalCheckout { public private(set) var supportedPrimerSessionIntents: [PrimerSessionIntent] = [] public private(set) var paymentMethodManagerCategories: [PrimerPaymentMethodManagerCategory] public private(set) var requiredInputDataClass: PrimerRawData.Type? + public private(set) var options: PrimerHeadlessUniversalCheckout.PaymentMethod.PrimerAvailablePaymentMethodsOptions init?(paymentMethodType: String) { guard let paymentMethod = PrimerAPIConfiguration.paymentMethodConfigs? @@ -54,14 +68,17 @@ extension PrimerHeadlessUniversalCheckout { guard let paymentMethodManagerCategories = paymentMethod.paymentMethodManagerCategories else { return nil } - self.paymentMethodManagerCategories = paymentMethodManagerCategories if PrimerPaymentMethodType.paymentCard.rawValue == paymentMethodType { requiredInputDataClass = PrimerCardData.self } + let captureVaultedCardCvv = (paymentMethod.options as? CardOptions)?.captureVaultedCardCvv ?? false + options = PrimerAvailablePaymentMethodsOptions(captureVaultedCardCvv: captureVaultedCardCvv) + super.init() } } } +// swiftlint:enable nesting diff --git a/Sources/PrimerSDK/Classes/Data Models/CardButtonViewModel.swift b/Sources/PrimerSDK/Classes/Data Models/CardButtonViewModel.swift index fa3198f2a9..7432fa5f99 100644 --- a/Sources/PrimerSDK/Classes/Data Models/CardButtonViewModel.swift +++ b/Sources/PrimerSDK/Classes/Data Models/CardButtonViewModel.swift @@ -1,6 +1,16 @@ import Foundation -struct CardButtonViewModel { +protocol CardButtonViewModelProtocol { + var network: String { get } + var cardholder: String { get } + var last4: String { get } + var expiry: String { get } + var imageName: ImageName { get } + var paymentMethodType: PaymentInstrumentType { get } + var surCharge: Int? { get } +} + +struct CardButtonViewModel: CardButtonViewModelProtocol { let network, cardholder, last4, expiry: String let imageName: ImageName @@ -12,8 +22,8 @@ struct CardButtonViewModel { .filter({ $0["type"] as? String == PrimerPaymentMethodType.paymentCard.rawValue }) .first else { return nil } guard let networks = paymentCardOption["networks"] as? [[String: Any]] else { return nil } - guard let tmpNetwork = networks.filter({ ($0["type"] as? String)? - .lowercased() == network.lowercased() }) + guard let tmpNetwork = networks + .filter({ ($0["type"] as? String)?.lowercased() == network.lowercased() }) .first else { return nil } return tmpNetwork["surcharge"] as? Int } diff --git a/Sources/PrimerSDK/Classes/Data Models/PaymentMethodConfigurationOptions.swift b/Sources/PrimerSDK/Classes/Data Models/PaymentMethodConfigurationOptions.swift index 35f54cb2c0..d25d82712c 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PaymentMethodConfigurationOptions.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PaymentMethodConfigurationOptions.swift @@ -21,6 +21,7 @@ struct CardOptions: PaymentMethodOptions { let threeDSecureInitUrl: String? let threeDSecureProvider: String let processorConfigId: String? + let captureVaultedCardCvv: Bool? } struct MerchantOptions: PaymentMethodOptions { diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCVVFieldView.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCVVFieldView.swift index f869f58283..2ee7684c64 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCVVFieldView.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCVVFieldView.swift @@ -76,19 +76,16 @@ public final class PrimerCVVFieldView: PrimerTextFieldView { primerTextField.internalText = newText primerTextField.text = newText + let isValidCVVLength: Bool? + if let cvvLength = cardNetwork.validation?.code.length { + isValidCVVLength = newText.count == cvvLength + } else { + isValidCVVLength = nil + } + switch validation { - case .valid: - if let cvvLength = cardNetwork.validation?.code.length, newText.count == cvvLength { - delegate?.primerTextFieldView(self, isValid: true) - } else { - delegate?.primerTextFieldView(self, isValid: nil) - } - case .invalid: - if let cvvLength = cardNetwork.validation?.code.length, newText.count == cvvLength { - delegate?.primerTextFieldView(self, isValid: false) - } else { - delegate?.primerTextFieldView(self, isValid: nil) - } + case .valid, .invalid: + delegate?.primerTextFieldView(self, isValid: isValidCVVLength) default: delegate?.primerTextFieldView(self, isValid: nil) } diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift new file mode 100644 index 0000000000..3e2e6ab713 --- /dev/null +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerCustomTextField.swift @@ -0,0 +1,153 @@ +// +// PrimerCustomTextField.swift +// PrimerSDK +// +// Created by Boris on 25.3.24.. +// + +import UIKit + +class PrimerCustomFieldView: UIView { + + var fieldView: PrimerTextFieldView! + override var tintColor: UIColor! { + didSet { + topPlaceholderLabel.textColor = tintColor + bottomLine.backgroundColor = tintColor + } + } + var placeholderText: String? + var rightImage1: UIImage? { + didSet { + rightImageView1Container.isHidden = rightImage1 == nil + rightImageView1.image = rightImage1 + } + } + var rightImage1TintColor: UIColor? { + didSet { + rightImageView1.tintColor = rightImage1TintColor + } + } + var rightImage2: UIImage? { + didSet { + rightImageView2.isHidden = rightImage2 == nil + rightImageView2.image = rightImage2 + } + } + var rightImage2TintColor: UIColor? { + didSet { + rightImageView2.tintColor = rightImage2TintColor + } + } + var errorText: String? { + didSet { + errorLabel.text = errorText ?? "" + } + } + + private var verticalStackView: UIStackView = UIStackView() + private let errorLabel = UILabel() + private let topPlaceholderLabel = UILabel() + private let rightImageView1Container = UIView() + private let rightImageView1 = UIImageView() // As in the one furthest right + private let rightImageView2Container = UIView() + private let rightImageView2 = UIImageView() + private let bottomLine = UIView() + private var theme: PrimerThemeProtocol = DependencyContainer.resolve() + + func setup() { + setupVerticalStackView() + setupTopPlaceholderLabel() + setupTextFieldStackView() + setupBottomLine() + setupErrorLabel() + constrainVerticalStackView() + } + + private func setupVerticalStackView() { + addSubview(verticalStackView) + verticalStackView.alignment = .fill + verticalStackView.axis = .vertical + } + + private func setupTopPlaceholderLabel() { + topPlaceholderLabel.font = UIFont.systemFont(ofSize: 10.0, weight: .medium) + topPlaceholderLabel.text = placeholderText + topPlaceholderLabel.textColor = theme.text.system.color + verticalStackView.addArrangedSubview(topPlaceholderLabel) + } + + private func setupTextFieldStackView() { + let textFieldStackView = createTextFieldStackView() + + setupRightImageView2Container(in: textFieldStackView) + setupRightImageView1Container(in: textFieldStackView) + + verticalStackView.addArrangedSubview(textFieldStackView) + } + + private func createTextFieldStackView() -> UIStackView { + let textFieldStackView = UIStackView() + textFieldStackView.alignment = .fill + textFieldStackView.axis = .horizontal + textFieldStackView.addArrangedSubview(fieldView) + textFieldStackView.spacing = 6 + return textFieldStackView + } + + private func setupRightImageView2Container(in stackView: UIStackView) { + rightImageView2.contentMode = .scaleAspectFit + + stackView.addArrangedSubview(rightImageView2Container) + rightImageView2Container.translatesAutoresizingMaskIntoConstraints = false + rightImageView2Container.addSubview(rightImageView2) + rightImageView2.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + rightImageView2.topAnchor.constraint(equalTo: rightImageView2Container.topAnchor, constant: 6), + rightImageView2.bottomAnchor.constraint(equalTo: rightImageView2Container.bottomAnchor, constant: -6), + rightImageView2.leadingAnchor.constraint(equalTo: rightImageView2Container.leadingAnchor), + rightImageView2.trailingAnchor.constraint(equalTo: rightImageView2Container.trailingAnchor), + rightImageView2.widthAnchor.constraint(equalTo: rightImageView2Container.heightAnchor) + ]) + } + + private func setupRightImageView1Container(in stackView: UIStackView) { + rightImageView1.contentMode = .scaleAspectFit + + stackView.addArrangedSubview(rightImageView1Container) + rightImageView1Container.translatesAutoresizingMaskIntoConstraints = false + rightImageView1Container.addSubview(rightImageView1) + rightImageView1.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + rightImageView1.topAnchor.constraint(equalTo: rightImageView1Container.topAnchor, constant: 10), + rightImageView1.bottomAnchor.constraint(equalTo: rightImageView1Container.bottomAnchor, constant: -10), + rightImageView1.leadingAnchor.constraint(equalTo: rightImageView1Container.leadingAnchor), + rightImageView1.trailingAnchor.constraint(equalTo: rightImageView1Container.trailingAnchor), + rightImageView1.widthAnchor.constraint(equalTo: rightImageView1Container.heightAnchor) + ]) + } + + private func setupBottomLine() { + bottomLine.backgroundColor = theme.colors.primary + bottomLine.heightAnchor.constraint(equalToConstant: 1).isActive = true + verticalStackView.addArrangedSubview(bottomLine) + } + + private func setupErrorLabel() { + errorLabel.textColor = theme.text.error.color + errorLabel.heightAnchor.constraint(equalToConstant: 12.0).isActive = true + errorLabel.font = UIFont.systemFont(ofSize: 10.0, weight: .medium) + errorLabel.text = nil + verticalStackView.addArrangedSubview(errorLabel) + } + + private func constrainVerticalStackView() { + verticalStackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + verticalStackView.topAnchor.constraint(equalTo: topAnchor), + verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerTextFieldView.swift b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerTextFieldView.swift index c0a9bdc404..386dd26045 100644 --- a/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerTextFieldView.swift +++ b/Sources/PrimerSDK/Classes/PCI/User Interface/Text Fields/PrimerTextFieldView.swift @@ -5,6 +5,7 @@ // Created by Evangelos Pittas on 29/6/21. // +// swiftlint:disable function_body_length import UIKit /// The PrimerTextFieldViewDelegate protocol can be used to retrieve information about the text input. @@ -12,10 +13,10 @@ import UIKit /// all have a delegate of PrimerTextFieldViewDelegate type. public protocol PrimerTextFieldViewDelegate: AnyObject { /// Will return true if valid, false if invalid, nil if it cannot be detected yet. - /// It is applied on all PrimerTextFieldViews. + /// It is applied on all PrimerTextFieldViews. func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, isValid: Bool?) /// Will return the card network (e.g. Visa) detected, unknown if the network cannot be detected. - /// Only applies on PrimerCardNumberFieldView + /// Only applies on PrimerCardNumberFieldView func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, didDetectCardNetwork cardNetwork: CardNetwork?) /// Will return a the validation error on the text input. func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, validationDidFailWithError error: Error) @@ -241,115 +242,6 @@ public class PrimerTextFieldView: PrimerNibView, UITextFieldDelegate { } -class PrimerCustomFieldView: UIView { - - var fieldView: PrimerTextFieldView! - override var tintColor: UIColor! { - didSet { - topPlaceholderLabel.textColor = tintColor - bottomLine.backgroundColor = tintColor - } - } - var placeholderText: String? - var rightImage1: UIImage? { - didSet { - rightImageView1Container.isHidden = rightImage1 == nil - rightImageView1.image = rightImage1 - } - } - var rightImage1TintColor: UIColor? { - didSet { - rightImageView1.tintColor = rightImage1TintColor - } - } - var rightImage2: UIImage? { - didSet { - rightImageView2.isHidden = rightImage2 == nil - rightImageView2.image = rightImage2 - } - } - var rightImage2TintColor: UIColor? { - didSet { - rightImageView2.tintColor = rightImage2TintColor - } - } - var errorText: String? { - didSet { - errorLabel.text = errorText ?? "" - } - } - - private var verticalStackView: UIStackView = UIStackView() - private let errorLabel = UILabel() - private let topPlaceholderLabel = UILabel() - private let rightImageView1Container = UIView() - private let rightImageView1 = UIImageView() // As in the one furthest right - private let rightImageView2Container = UIView() - private let rightImageView2 = UIImageView() - private let bottomLine = UIView() - private var theme: PrimerThemeProtocol = DependencyContainer.resolve() - - func setup() { - addSubview(verticalStackView) - verticalStackView.alignment = .fill - verticalStackView.axis = .vertical - - topPlaceholderLabel.font = UIFont.systemFont(ofSize: 10.0, weight: .medium) - topPlaceholderLabel.text = placeholderText - topPlaceholderLabel.textColor = theme.text.system.color - verticalStackView.addArrangedSubview(topPlaceholderLabel) - - rightImageView1.contentMode = .scaleAspectFit - rightImageView2.contentMode = .scaleAspectFit - - let textFieldStackView = UIStackView() - textFieldStackView.alignment = .fill - textFieldStackView.axis = .horizontal - textFieldStackView.addArrangedSubview(fieldView) - textFieldStackView.addArrangedSubview(rightImageView2) - textFieldStackView.spacing = 6 - - textFieldStackView.addArrangedSubview(rightImageView2Container) - rightImageView2Container.translatesAutoresizingMaskIntoConstraints = false - rightImageView2Container.addSubview(rightImageView2) - rightImageView2.translatesAutoresizingMaskIntoConstraints = false - rightImageView2.topAnchor.constraint(equalTo: rightImageView2Container.topAnchor, constant: 6).isActive = true - rightImageView2.bottomAnchor.constraint(equalTo: rightImageView2Container.bottomAnchor, constant: -6).isActive = true - rightImageView2.leadingAnchor.constraint(equalTo: rightImageView2Container.leadingAnchor, constant: 0).isActive = true - rightImageView2.trailingAnchor.constraint(equalTo: rightImageView2Container.trailingAnchor, constant: 0).isActive = true - rightImageView2.widthAnchor.constraint(equalTo: rightImageView2Container.heightAnchor, multiplier: 1.0).isActive = true - - textFieldStackView.addArrangedSubview(rightImageView1Container) - rightImageView1Container.translatesAutoresizingMaskIntoConstraints = false - rightImageView1Container.addSubview(rightImageView1) - rightImageView1.translatesAutoresizingMaskIntoConstraints = false - rightImageView1.topAnchor.constraint(equalTo: rightImageView1Container.topAnchor, constant: 10).isActive = true - rightImageView1.bottomAnchor.constraint(equalTo: rightImageView1Container.bottomAnchor, constant: -10).isActive = true - rightImageView1.leadingAnchor.constraint(equalTo: rightImageView1Container.leadingAnchor, constant: 0).isActive = true - rightImageView1.trailingAnchor.constraint(equalTo: rightImageView1Container.trailingAnchor, constant: 0).isActive = true - rightImageView1.widthAnchor.constraint(equalTo: rightImageView1.heightAnchor, multiplier: 1.0).isActive = true - - bottomLine.backgroundColor = theme.colors.primary - bottomLine.heightAnchor.constraint(equalToConstant: 1).isActive = true - verticalStackView.addArrangedSubview(textFieldStackView) - verticalStackView.addArrangedSubview(bottomLine) - - errorLabel.textColor = theme.text.error.color - errorLabel.heightAnchor.constraint(equalToConstant: 12.0).isActive = true - errorLabel.font = UIFont.systemFont(ofSize: 10.0, weight: .medium) - errorLabel.text = nil - - verticalStackView.addArrangedSubview(errorLabel) - - verticalStackView.translatesAutoresizingMaskIntoConstraints = false - verticalStackView.topAnchor.constraint(equalTo: topAnchor, constant: 0).isActive = true - verticalStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0).isActive = true - verticalStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0).isActive = true - verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0).isActive = true - } - -} - internal class PaddedImageView: PrimerImageView { internal private(set) var insets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) @@ -378,5 +270,5 @@ internal class PaddedImageView: PrimerImageView { required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } - } +// swiftlint:enable function_body_length diff --git a/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift b/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift index dd92cfdfe1..b849486d72 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift @@ -38,7 +38,7 @@ internal class CardButton: PrimerButton { toggleIcon() } - addCardIcon(image: model.imageName.image) + addCardIcon(image: CardNetwork(cardNetworkStr: model.network).icon) addBorder() switch model.paymentMethodType { diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewController.swift new file mode 100644 index 0000000000..9811c202b9 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewController.swift @@ -0,0 +1,176 @@ +// +// CVVRecaptureViewController.swift +// PrimerSDK +// +// Created by Boris on 28.2.24.. +// + +import UIKit + +class CVVRecaptureViewController: UIViewController { + + var viewModel: CVVRecaptureViewModel + private let theme: PrimerThemeProtocol = DependencyContainer.resolve() + private let explanationLabel = UILabel() + private let imageView = UIImageView() + private let cardNumberLabel = UILabel() + private var cvvField: PrimerCVVFieldView! + private var cvvContainerView: PrimerCustomFieldView! + private let continueButton = PrimerButton() + + // Designated initializer + init(viewModel: CVVRecaptureViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + // Required initializer for decoding + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + bindViewModel() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + _ = cvvField.becomeFirstResponder() + } + + private func bindViewModel() { + viewModel.onContinueButtonStateChange = { [weak self] isEnabled in + if self?.continueButton.isAnimating == true { return } + self?.continueButton.isEnabled = isEnabled + let continueButtonColor = self?.theme.mainButton.color(for: isEnabled ? .enabled : .disabled) + self?.continueButton.backgroundColor = continueButtonColor + } + } + + // MARK: - View Setup Functions + private let padding: CGFloat = 16.0 + private let height: CGFloat = 48.0 + private let defaultElementDistance: CGFloat = 24.0 + private func setupViews() { + title = Strings.CVVRecapture.title + setupExplanationLabel() + setupImageView() + setupCardNumberLabel() + setupCVVContainerView() + setupContinueButton(with: Strings.CVVRecapture.buttonTitle) + } + + private func setupExplanationLabel() { + let explanationText = String(format: Strings.CVVRecapture.explanation, viewModel.cvvLength) + explanationLabel.text = explanationText + explanationLabel.numberOfLines = 0 + explanationLabel.textColor = theme.text.body.color + explanationLabel.font = .systemFont(ofSize: CGFloat(theme.text.body.fontSize)) + view.addSubview(explanationLabel) + activateExplanationLabelConstraints() + } + + private func setupImageView() { + let networkIcon = CardNetwork(cardNetworkStr: viewModel.cardButtonViewModel.network).icon + imageView.image = networkIcon + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFit + view.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + activateImageViewConstraints() + } + + private func setupCardNumberLabel() { + cardNumberLabel.text = viewModel.cardButtonViewModel.last4 + cardNumberLabel.textColor = theme.text.body.color + cardNumberLabel.font = .systemFont(ofSize: CGFloat(theme.text.body.fontSize), weight: .bold) + view.addSubview(cardNumberLabel) + cardNumberLabel.translatesAutoresizingMaskIntoConstraints = false + activateCardNumberLabelConstraints() + } + + private func setupCVVContainerView() { + cvvField = PrimerCVVField.cvvFieldViewWithDelegate(self) + cvvField.cardNetwork = CardNetwork(cardNetworkStr: viewModel.cardButtonViewModel.network) + cvvField.isValid = { text in + let cardNetwork = CardNetwork(cardNetworkStr: self.viewModel.cardButtonViewModel.network) + return !text.isEmpty && text.isValidCVV(cardNetwork: cardNetwork) + } + cvvContainerView = PrimerCVVField.cvvContainerViewFieldView(cvvField) + view.addSubview(cvvContainerView) + cvvContainerView.translatesAutoresizingMaskIntoConstraints = false + activateCVVContainerViewConstraints() + } + + private func setupContinueButton(with title: String) { + continueButton.layer.cornerRadius = 4 + continueButton.setTitle(title, for: .normal) + continueButton.setTitleColor(theme.mainButton.text.color, for: .normal) + continueButton.titleLabel?.font = .boldSystemFont(ofSize: 19) + continueButton.backgroundColor = theme.mainButton.color(for: .enabled) + continueButton.addTarget(self, action: #selector(continueButtonTapped), for: .touchUpInside) + continueButton.heightAnchor.constraint(equalToConstant: 48).isActive = true + continueButton.isEnabled = false + continueButton.backgroundColor = theme.mainButton.color(for: .disabled) + view.addSubview(continueButton) + continueButton.translatesAutoresizingMaskIntoConstraints = false + activateContinueButtonConstraints() + } + + private func activateExplanationLabelConstraints() { + explanationLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + explanationLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: padding), + explanationLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), + explanationLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding) + ]) + } + + private func activateImageViewConstraints() { + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: explanationLabel.bottomAnchor, constant: defaultElementDistance), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), + imageView.widthAnchor.constraint(equalToConstant: 56), + imageView.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + private func activateCardNumberLabelConstraints() { + NSLayoutConstraint.activate([ + cardNumberLabel.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + cardNumberLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: padding) + ]) + } + + private func activateCVVContainerViewConstraints() { + NSLayoutConstraint.activate([ + cvvContainerView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + cvvContainerView.leadingAnchor.constraint(equalTo: cardNumberLabel.trailingAnchor, constant: defaultElementDistance), + cvvContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding) + ]) + } + + private func activateContinueButtonConstraints() { + NSLayoutConstraint.activate([ + continueButton.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: defaultElementDistance), + continueButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), + continueButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding), + continueButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: padding) + ]) + } + + @objc private func continueButtonTapped() { + continueButton.startAnimating() + continueButton.isEnabled = false + viewModel.continueButtonTapped(with: cvvField.cvv) + } +} + +extension CVVRecaptureViewController: PrimerTextFieldViewDelegate { + func primerTextFieldView(_ primerTextFieldView: PrimerTextFieldView, isValid: Bool?) { + viewModel.isValidCvv = isValid ?? true + } +} diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewModel.swift b/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewModel.swift new file mode 100644 index 0000000000..3077dd5bf1 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Root/CVVRecapture/CVVRecaptureViewModel.swift @@ -0,0 +1,35 @@ +// +// CVVRecaptureViewModel.swift +// PrimerSDK +// +// Created by Boris on 29.2.24.. +// + +import Foundation + +class CVVRecaptureViewModel { + + var didSubmitCvv: ((String) -> Void)? + var cardButtonViewModel: CardButtonViewModelProtocol! + var onContinueButtonStateChange: ((Bool) -> Void)? + + var isValidCvv: Bool = false { + didSet { + onContinueButtonStateChange?(isValidCvv) + } + } + + var cvvLength: Int { + let network = CardNetwork(cardNetworkStr: cardButtonViewModel.network) + return network.validation?.code.length ?? 3 + } + + private let theme: PrimerThemeProtocol = DependencyContainer.resolve() + + // Logic to handle continue button tap + func continueButtonTapped(with cvv: String) { + if isValidCvv { + didSubmitCvv?(cvv) + } + } +} diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerRootViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerRootViewController.swift index 6084673d54..25b3e0280d 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerRootViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerRootViewController.swift @@ -221,7 +221,7 @@ internal class PrimerRootViewController: PrimerViewController { view.layoutIfNeeded() } - internal func show(viewController: UIViewController) { + internal func show(viewController: UIViewController, animated: Bool = false) { viewController.view.translatesAutoresizingMaskIntoConstraints = false viewController.view.widthAnchor.constraint(equalToConstant: self.childView.frame.width).isActive = true viewController.view.layoutIfNeeded() @@ -253,7 +253,7 @@ internal class PrimerRootViewController: PrimerViewController { } if isPresented { - self.navController.setViewControllers([cvc], animated: false) + self.navController.setViewControllers([cvc], animated: animated) let container = PrimerViewController() container.addChild(self.navController) @@ -271,7 +271,7 @@ internal class PrimerRootViewController: PrimerViewController { container.view.bottomAnchor.constraint(equalTo: self.childView.bottomAnchor, constant: 0).isActive = true container.didMove(toParent: self) } else { - self.navController.pushViewController(viewController: cvc, animated: false) { + self.navController.pushViewController(viewController: cvc, animated: animated) { var viewControllers = self.navController.viewControllers for (index, viewController) in viewControllers.enumerated().reversed() { // If the loading screen is the last one in the stack, do not remove it yet. @@ -353,9 +353,8 @@ internal class PrimerRootViewController: PrimerViewController { } } - internal func popViewController() { + internal func popViewController(animated: Bool = false, completion: (() -> Void)? = nil) { let index = navController.viewControllers.count-2 - guard navController.viewControllers.count > 1, let viewController = (navController.viewControllers[index] as? PrimerContainerViewController)?.childViewController else { @@ -371,12 +370,12 @@ internal class PrimerRootViewController: PrimerViewController { childViewHeightConstraint.constant = navigationControllerHeight + bottomPadding - navController.popViewController(animated: false) + navController.popViewController(animated: animated) UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { self.view.layoutIfNeeded() } completion: { _ in - + completion?() } } diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift index c34db95534..e20b92f5f6 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift @@ -23,6 +23,7 @@ internal class PrimerUniversalCheckoutViewController: PrimerFormViewController { private var onClientSessionActionUpdateCompletion: ((Error?) -> Void)? private var singleUsePaymentMethod: PrimerPaymentMethodTokenData? private var resumePaymentId: String? + private var cardButtonViewModel: CardButtonViewModel! override func viewDidLoad() { super.viewDidLoad() @@ -108,6 +109,7 @@ internal class PrimerUniversalCheckoutViewController: PrimerFormViewController { if let selectedPaymentMethod = universalCheckoutViewModel.selectedPaymentMethod, let cardButtonViewModel = selectedPaymentMethod.cardButtonViewModel { + self.cardButtonViewModel = cardButtonViewModel self.selectedPaymentMethod = selectedPaymentMethod if savedPaymentMethodStackView == nil { @@ -288,20 +290,37 @@ internal class PrimerUniversalCheckoutViewController: PrimerFormViewController { ) Analytics.Service.record(event: viewEvent) - enableView(false) - payButton.startAnimating() - - let checkoutWithVaultedPaymentMethodVM = CheckoutWithVaultedPaymentMethodViewModel(configuration: config, - selectedPaymentMethodTokenData: selectedPaymentMethod) - firstly { - checkoutWithVaultedPaymentMethodVM.start() + if let captureVaultedCardCvv = (config.options as? CardOptions)?.captureVaultedCardCvv, + captureVaultedCardCvv == true { + let cvvViewController = CVVRecaptureViewController(viewModel: CVVRecaptureViewModel()) + cvvViewController.viewModel.cardButtonViewModel = cardButtonViewModel + cvvViewController.viewModel.didSubmitCvv = { cvv in + let cvvData = PrimerVaultedCardAdditionalData(cvv: cvv) + startCheckout(withAdditionalData: cvvData) + } + PrimerUIManager.primerRootViewController?.show(viewController: cvvViewController, animated: true) + } else { + startCheckout(withAdditionalData: nil) } - .ensure { - self.enableView(true) + + // Common functionality to start the checkout process + func startCheckout(withAdditionalData additionalData: PrimerVaultedCardAdditionalData?) { + payButton.startAnimating() + enableView(false) + + let checkoutWithVaultedPaymentMethodVM = CheckoutWithVaultedPaymentMethodViewModel(configuration: config, + selectedPaymentMethodTokenData: selectedPaymentMethod, + additionalData: additionalData) + firstly { + checkoutWithVaultedPaymentMethodVM.start() + } + .ensure { + self.payButton.stopAnimating() + self.enableView(true) + } + .catch { _ in } } - .catch { _ in } } - } extension PrimerUniversalCheckoutViewController { diff --git a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift index e793257b24..8c9743fbfc 100644 --- a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift +++ b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift @@ -22,8 +22,8 @@ class CheckoutWithVaultedPaymentMethodViewModel: LogReporter { var paymentMethodTokenData: PrimerPaymentMethodTokenData! var paymentCheckoutData: PrimerCheckoutData? var successMessage: String? - var resumePaymentId: String? + var additionalData: PrimerVaultedCardAdditionalData? // Events var didStartTokenization: (() -> Void)? @@ -35,9 +35,12 @@ class CheckoutWithVaultedPaymentMethodViewModel: LogReporter { var willDismissPaymentMethodUI: (() -> Void)? var didDismissPaymentMethodUI: (() -> Void)? - init(configuration: PrimerPaymentMethod, selectedPaymentMethodTokenData: PrimerPaymentMethodTokenData) { + init(configuration: PrimerPaymentMethod, + selectedPaymentMethodTokenData: PrimerPaymentMethodTokenData, + additionalData: PrimerVaultedCardAdditionalData?) { self.config = configuration self.selectedPaymentMethodTokenData = selectedPaymentMethodTokenData + self.additionalData = additionalData } func start() -> Promise { @@ -116,7 +119,7 @@ class CheckoutWithVaultedPaymentMethodViewModel: LogReporter { } let tokenizationService = TokenizationService() - return tokenizationService.exchangePaymentMethodToken(paymentMethodTokenId, vaultedPaymentMethodAdditionalData: nil) + return tokenizationService.exchangePaymentMethodToken(paymentMethodTokenId, vaultedPaymentMethodAdditionalData: self.additionalData) } .then { paymentMethodTokenData -> Promise in self.paymentMethodTokenData = paymentMethodTokenData diff --git a/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift index 6af9f7734d..05cad32273 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift @@ -109,7 +109,12 @@ internal class VaultedPaymentInstrumentCell: UITableViewCell { verticalRightStackView.distribution = .fillEqually verticalRightStackView.spacing = 0 - cardNetworkImageView.image = paymentMethod.cardButtonViewModel?.imageName.image + if let network = paymentMethod.cardButtonViewModel?.network { + let cardNetworkImage = CardNetwork(cardNetworkStr: network).icon + cardNetworkImageView.image = cardNetworkImage + } else { + cardNetworkImageView.image = paymentMethod.cardButtonViewModel?.imageName.image + } cardNetworkImageView.contentMode = .scaleAspectFit checkmarkImageView.image = isDeleting ? diff --git a/Sources/PrimerSDK/Resources/Localizable/ar.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/ar.lproj/Localizable.strings index 909e5224e6..65ae7eda30 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/ar.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/ar.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/da.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/da.lproj/Localizable.strings index 59f30ca3bc..7fc0ab0fa7 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/da.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/da.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/de.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/de.lproj/Localizable.strings index 59bbbc46bf..0bf1ccd171 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/de.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/de.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/el.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/el.lproj/Localizable.strings index f596bb3dba..21bc167d1f 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/el.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/el.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings index d35850632d..77267cff65 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/en.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/es.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/es.lproj/Localizable.strings index 57beadef3d..a1fbe0bb65 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/es.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/es.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/fr.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/fr.lproj/Localizable.strings index c5385e6950..00cc929d77 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/fr.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/fr.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings index 6383444c90..3d36c5175a 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/it.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/ms.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/ms.lproj/Localizable.strings index af622bf77c..3cdf62a36d 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/ms.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/ms.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/nb.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/nb.lproj/Localizable.strings index 8e6fc3ce23..e226e3a878 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/nb.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/nb.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/nl.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/nl.lproj/Localizable.strings index 182e7cf159..73a31abb17 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/nl.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/nl.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/pl.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/pl.lproj/Localizable.strings index 529cc4aba6..bd5da92336 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/pl.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/pl.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/pt.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/pt.lproj/Localizable.strings index 3d4165fa22..5eb2dd2398 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/pt.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/pt.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/sv.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/sv.lproj/Localizable.strings index 18be445ba2..00a88d047b 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/sv.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/sv.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/th.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/th.lproj/Localizable.strings index 81e4387d21..b5657975a8 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/th.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/th.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/tr.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/tr.lproj/Localizable.strings index c4c161bf23..72fd5e27ca 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/tr.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/tr.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/zh-CN.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/zh-CN.lproj/Localizable.strings index 35618eea2d..51badb04da 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/zh-CN.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/zh-CN.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/zh-HK.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/zh-HK.lproj/Localizable.strings index b204678468..78830543b5 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/zh-HK.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/zh-HK.lproj/Localizable.strings differ diff --git a/Sources/PrimerSDK/Resources/Localizable/zh-TW.lproj/Localizable.strings b/Sources/PrimerSDK/Resources/Localizable/zh-TW.lproj/Localizable.strings index 9bb83fff2b..aff2ec1eb4 100644 Binary files a/Sources/PrimerSDK/Resources/Localizable/zh-TW.lproj/Localizable.strings and b/Sources/PrimerSDK/Resources/Localizable/zh-TW.lproj/Localizable.strings differ