From a4e5f8bc31be84eaaf1234d0db639f3799b41b46 Mon Sep 17 00:00:00 2001 From: StefanV-PRIMERIO <155630313+StefanV-PRIMERIO@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:56:53 +0200 Subject: [PATCH 1/3] feat: Klarna Drop-IN Reskin (#822) * chore: check bstack modification on appium tests * chore: revert ui-tests file change * chore: clean and nits * chore: Klarna Headless classes + refactor only * chore: update event * chore: added merchant vc logic * chore: enabled validate functionality to be called * chore: added unit tests to conform with new refactorization * chore: Hide the components logic and expose only KlarnaHeadlessManager to the merchant * chore: update logic to use validate method * chore: Refactored merchant vc * chore: updated some more unit tests * chore: refactor Klarna request and response bodies * chore: added values for CreatePaymentSession needed for one-time payment session * chore: hide unnecessary UI and logic for checkout intent * chore: update payment intent from MerchantHeadlessCheckoutAvailablePaymentMethodsViewController * chore: some more refactoring and alignment with android objects * chore: added MerchantHelpers with MerchantMockDataManager * chore: added client-session/actions request and refactor KlarnaTokenizationComponent * chore: KlarnaHelpers refactor * chore: Added Finalization logic for KlarnaTokenizationComponent * chore: update unit tests * chore: Finalization request and payment instruments logic * chore: fixed failing unit tests * chore: added KlarnaTokenizationComponentTests * chore: added KlarnaHeadlessManager unit tests * chore: moved KlarnaTestsMocks * chore: updated unit tests and added KlarnaTokenizationManagerTests * chore: update failing test * chore: refactor async methods to use Promise kit * chore: updated unit tests for Promise * chore: update unit test * chore: added switch button to be able to switch between generic and klarna session * chore: resolve conflicts * chore: added description comments to the KlarnaHeadlessManager methods * chore: fixed unit test * chore: removed the tokenization logic from PaymentMethod and PrimerConfiguration * chore: changed clientSession setup logic for MerchantSessionAndSettingsViewController * chore: updated one time payment option UI * chore: update failing test tests and pods * chore: fixed merge errors * chore: remove shared intent public exposure and implement it in klarna manager * chore: update KlarnaHeadlessManager re-order the properties * chore: updated unit test * chore: refactor KlarnaPaymentSessionCreationComponent to remove unused logic * chore: refactor Create Payment Session * chore: refactored authorization and creation components * chore: refactor KlarnaPaymentViewHandlingComponent and FinalizationComponent * chore: nits * chore: added nits and comments to Authorization and Creation components * chore: added /payments call after tokenize for checkoutData * chore: update KlarnaAuthorizationTokenPaymentInstrument * chore: added loader for merchant page * chore: refactor to align with Android * chore: updated unit tests * chore: Alignment feedback on merchant page side * chore: update merchant vc * chore: update alignment with android feedback * chore: update Android alignment * chore: finalize alignment with android * chore: refactored klarna manager * chore: change naming protocols and internal logic for KlarnaComponent * chore: update analytics vars and unit tests * chore: update regarding public api's * chore: android documentation alignment * chore: update unit tests * chore: update comments nits * chore: fix swift lint warnings * chore: fix lint issues * chore: update with identifiers for E2E tests * chore: update authorize method for E2E tests * chore: update SPM * chore: alignment with android Validation, Error and Event tracking alignment * chore: fix lint issues and fix unit tests * chore: update swift lint errors * chore: more lint issues fixed * feat: Klarna Headless Extra Merchant Data (#809) * chore: adding emd serializing and deserializing logic * chore: removed attachment logic from createSession * chore: update Klarna payment method options * chore: update session EMD hardcoded value * chore: rebase * chore: refactor PaymentMethodOptions decoder keeping polymorphism * chore: added unit test for emd * chore: fix lint issues * chore: PR review updates * chore: fix more lint issues * chore: fix for pod lint * chore: alignment updates * chore: refactor KlarnaTokenizationViewModel factoring in KlarnaTokenzizationManager * chore: update KlarnaTokenizationVM * chore: added Klarna categories viewcontroller * chore: added navbar and continue button * chore: update PrimerHeadlessKlarnaComponent update PrimerHeadlessKlarnaComponent to take into account drop-in logic added ui validations * chore: refactoring loading states * chore: added, update and fixed unit tests * chore: added unit tests for PrimerKlarnaCategoriesViewController * chore: cleanup swift lint warnings * chore: lint warnings fixed * chore: fix error on lint pod * chore: pod lint fix * chore: update for E2E tests * chore: fix swift lint warning * chore: PR change requests * chore: conflict fix addon --- .../project.pbxproj | 4 + .../Primer/Klarna/KlarnaTestsMocks.swift | 19 +- .../KlarnaTokenizationManagerTests.swift | 258 +++++---- ...rKlarnaCategoriesViewControllerTests.swift | 69 +++ ...KlarnaComponent+SessionAuthorization.swift | 16 +- ...sKlarnaComponent+SessionFinalization.swift | 8 +- .../PrimerHeadlessKlarnaComponent.swift | 12 +- .../Managers/KlarnaTokenizationManager.swift | 104 ++-- .../Klarna/Models/KlarnaHelpers.swift | 44 ++ .../Klarna/Models/KlarnaPaymentCategory.swift | 1 + .../Classes/Data Models/PaymentMethod.swift | 4 +- .../PrimerKlarnaCategoriesElements.swift | 123 +++++ .../PrimerKlarnaCategoriesView.swift | 77 +++ ...PrimerKlarnaCategoriesViewController.swift | 221 ++++++++ .../PrimerKlarnaCategoriesViewModel.swift | 23 + .../KlarnaTokenizationViewModel.swift | 507 ++---------------- ...entMethodTokenizationViewModel+Logic.swift | 208 ++++--- .../Common/arrow-left.imageset/Contents.json | 23 + .../arrow-left.imageset/arrow-left-1.png | Bin 0 -> 955 bytes .../arrow-left.imageset/arrow-left-2.png | Bin 0 -> 964 bytes .../Common/arrow-left.imageset/arrow-left.png | Bin 0 -> 814 bytes .../Contents.json | 15 + .../klarna_payment_category.pdf | Bin 0 -> 1647 bytes 23 files changed, 1014 insertions(+), 722 deletions(-) create mode 100644 Debug App/Tests/Unit Tests/Primer/Klarna/PrimerKlarnaCategoriesViewControllerTests.swift create mode 100644 Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesElements.swift create mode 100644 Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesView.swift create mode 100644 Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewController.swift create mode 100644 Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewModel.swift create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/Contents.json create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-1.png create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-2.png create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left.png create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Icons/klarna_payment_category.imageset/Contents.json create mode 100644 Sources/PrimerSDK/Resources/Icons.xcassets/Icons/klarna_payment_category.imageset/klarna_payment_category.pdf diff --git a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj index 408ff55fb4..121c89f343 100644 --- a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj +++ b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 876141082B8346650058CA8C /* MerchantHeadlessKlarnaInitializationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876141072B8346650058CA8C /* MerchantHeadlessKlarnaInitializationView.swift */; }; 8761410A2B8355920058CA8C /* MerchantHeadlessKlarnaInitializationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876141092B8355920058CA8C /* MerchantHeadlessKlarnaInitializationViewModel.swift */; }; 8761410C2B849A250058CA8C /* MerchantHeadlessKlarnaInitializationView+Elements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8761410B2B849A250058CA8C /* MerchantHeadlessKlarnaInitializationView+Elements.swift */; }; + 87DF3EC72BA871BD00162100 /* PrimerKlarnaCategoriesViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DF3EC62BA871BD00162100 /* PrimerKlarnaCategoriesViewControllerTests.swift */; }; 8B5CB0C992DBAB293D378FAD /* MockModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA7D2BB0AC0A1877DB2E6CE /* MockModule.swift */; }; 91FAC91E687B6981268E677E /* DateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA0B2BEE5C389FD13E210847 /* DateTests.swift */; }; 9263BD762EC26F6AA986F0C9 /* MockAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17476BFBED51F389FCE82F16 /* MockAPIClient.swift */; }; @@ -263,6 +264,7 @@ 876141072B8346650058CA8C /* MerchantHeadlessKlarnaInitializationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantHeadlessKlarnaInitializationView.swift; sourceTree = ""; }; 876141092B8355920058CA8C /* MerchantHeadlessKlarnaInitializationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantHeadlessKlarnaInitializationViewModel.swift; sourceTree = ""; }; 8761410B2B849A250058CA8C /* MerchantHeadlessKlarnaInitializationView+Elements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MerchantHeadlessKlarnaInitializationView+Elements.swift"; sourceTree = ""; }; + 87DF3EC62BA871BD00162100 /* PrimerKlarnaCategoriesViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimerKlarnaCategoriesViewControllerTests.swift; sourceTree = ""; }; 8A23886804B13FA754E775D0 /* MockPaymentMethodTokenizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPaymentMethodTokenizationViewModel.swift; sourceTree = ""; }; 8A3FDC6FE0EB5AB5828D4D80 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/LaunchScreen.strings; sourceTree = ""; }; 8C7C082270CF1C6B7810F9B3 /* RawDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawDataManagerTests.swift; sourceTree = ""; }; @@ -589,6 +591,7 @@ 876140E12B66F89D0058CA8C /* KlarnaHeadlessManagerTests.swift */, 876140F32B70FBFE0058CA8C /* KlarnaTokenizationComponentTests.swift */, 876140F52B716E060058CA8C /* KlarnaTokenizationManagerTests.swift */, + 87DF3EC62BA871BD00162100 /* PrimerKlarnaCategoriesViewControllerTests.swift */, ); path = Klarna; sourceTree = ""; @@ -1097,6 +1100,7 @@ 042ED1622AF0F5600027833F /* MockBINDataAPIClient.swift in Sources */, 5976CCA261F0811F5D7707DA /* TokenizationService.swift in Sources */, 583EBAA90902121CEA479416 /* VaultService.swift in Sources */, + 87DF3EC72BA871BD00162100 /* PrimerKlarnaCategoriesViewControllerTests.swift in Sources */, 961B5D18058EF4CFCD0185AE /* MockVaultCheckoutViewModel.swift in Sources */, 622A605DDEA98D981670B53F /* DropInUI_TokenizationViewModelTests.swift in Sources */, F03699592AC2E63700E4179D /* BuildFile in Sources */, diff --git a/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTestsMocks.swift b/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTestsMocks.swift index 53d3cb5e6d..efeff24bc5 100644 --- a/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTestsMocks.swift +++ b/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTestsMocks.swift @@ -27,6 +27,19 @@ class KlarnaTestsMocks { diagnosticsId: UUID().uuidString ) + static let primerPaymentMethodTokenData = PrimerPaymentMethodTokenData( + analyticsId: "mock_analytics_id", + id: "mock_payment_method_token_data_id", + isVaulted: false, + isAlreadyVaulted: false, + paymentInstrumentType: .klarnaCustomerToken, + paymentMethodType: "KLARNA", + paymentInstrumentData: nil, + threeDSecureAuthentication: nil, + token: "mock_payment_method_token", + tokenType: .singleUse, + vaultData: nil) + static var extraMerchantData: [String: Any] = [ "subscription": [ [ @@ -73,9 +86,9 @@ class KlarnaTestsMocks { static func getMockPrimerApiConfiguration(clientSession: ClientSession.APIResponse) -> Response.Body.Configuration { return Response.Body.Configuration( - coreUrl: "https://primer.io/core", - pciUrl: "https://primer.io/pci", - binDataUrl: "https://bindata.url", + coreUrl: "https://core.primer.io", + pciUrl: "https://pci.primer.io", + binDataUrl: "https://primer.io/bindata", assetsUrl: "https://assets.staging.core.primer.io", clientSession: clientSession, paymentMethods: [ diff --git a/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTokenizationManagerTests.swift b/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTokenizationManagerTests.swift index 08c30be37f..1f8ad59bc6 100644 --- a/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTokenizationManagerTests.swift +++ b/Debug App/Tests/Unit Tests/Primer/Klarna/KlarnaTokenizationManagerTests.swift @@ -12,104 +12,170 @@ import XCTest final class KlarnaTokenizationManagerTests: XCTestCase { - // var tokenizationComponent: KlarnaTokenizationComponent! - // - // override func setUp() { - // super.setUp() - // prepareConfigurations() - // } - // - // override func tearDown() { - // restartPrimerConfiguration() - // super.tearDown() - // } - // - // func test_tokenize_success() { - // let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: true) - // let expectation = XCTestExpectation(description: "Successful Tokenize Klarna Payment Session") - // - // firstly { - // tokenizationComponent.tokenize(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) - // } - // .done { tokenData in - // XCTAssertNotNil(tokenData, "Result should not be nil") - // expectation.fulfill() - // } - // .catch { _ in - // expectation.fulfill() - // } - // - // wait(for: [expectation], timeout: 10.0) - // } - // - // func test_tokenize_failure() { - // let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: false) - // let expectation = XCTestExpectation(description: "Failure Tokenize Klarna Payment Session") - // - // firstly { - // tokenizationComponent.tokenize(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) - // } - // .done { tokenData in - // XCTFail("Result should be nil") - // expectation.fulfill() - // } - // .catch { error in - // XCTAssertNotNil(error, "Error should not be nil") - // expectation.fulfill() - // } - // - // wait(for: [expectation], timeout: 10.0) - // } + var tokenizationManager: MockKlarnaTokenizationManager! + + override func setUp() { + super.setUp() + prepareConfigurations() + } + + override func tearDown() { + restartPrimerConfiguration() + super.tearDown() + } + + func test_tokenizeHeadless_success() { + let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: true) + let expectation = XCTestExpectation(description: "Successful Tokenize Klarna Payment Session") + tokenizationManager.mockedSuccessValue = true + + firstly { + tokenizationManager.tokenizeHeadless(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) + } + .done { tokenData in + XCTAssertNotNil(tokenData, "Result should not be nil") + expectation.fulfill() + } + .catch { _ in + XCTFail("Result should be nil") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + } + + func test_tokenizeHeadless_failure() { + let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: false) + let expectation = XCTestExpectation(description: "Failure Tokenize Klarna Payment Session") + tokenizationManager.mockedSuccessValue = false + + firstly { + tokenizationManager.tokenizeHeadless(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) + } + .done { tokenData in + XCTFail("Result should be nil") + expectation.fulfill() + } + .catch { error in + XCTAssertNotNil(error, "Error should not be nil") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + } + + func test_tokenizeDropIn_success() { + let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: true) + let expectation = XCTestExpectation(description: "Successful Tokenize Klarna Payment Session") + tokenizationManager.mockedSuccessValue = true + + firstly { + tokenizationManager.tokenizeDropIn(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) + } + .done { tokenData in + XCTAssertNotNil(tokenData, "Result should not be nil") + expectation.fulfill() + } + .catch { _ in + XCTFail("Result should be nil") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + } + + func test_tokenizeDropIn_failure() { + let finalizePaymentData = KlarnaTestsMocks.getMockFinalizeKlarnaPaymentSession(isValid: false) + let expectation = XCTestExpectation(description: "Failure Tokenize Klarna Payment Session") + tokenizationManager.mockedSuccessValue = false + + firstly { + tokenizationManager.tokenizeDropIn(customerToken: finalizePaymentData, offSessionAuthorizationId: finalizePaymentData.customerTokenId) + } + .done { tokenData in + XCTFail("Result should be nil") + expectation.fulfill() + } + .catch { error in + XCTAssertNotNil(error, "Error should not be nil") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + } } -// extension KlarnaTokenizationManagerTests { -// private func setupPrimerConfiguration(paymentMethod: PrimerPaymentMethod, apiConfiguration: PrimerAPIConfiguration) { -// let mockApiClient = MockPrimerAPIClient() -// mockApiClient.fetchConfigurationWithActionsResult = (apiConfiguration, nil) -// mockApiClient.mockSuccessfulResponses() -// -// AppState.current.clientToken = KlarnaTestsMocks.clientToken -// PrimerAPIConfigurationModule.apiClient = mockApiClient -// PrimerAPIConfigurationModule.clientToken = KlarnaTestsMocks.clientToken -// PrimerAPIConfigurationModule.apiConfiguration = apiConfiguration -// -// tokenizationComponent = KlarnaTokenizationComponent(paymentMethod: paymentMethod) -// } -// -// private func prepareConfigurations() { -// PrimerInternal.shared.intent = .checkout -// let clientSession = KlarnaTestsMocks.getClientSession() -// let successApiConfiguration = KlarnaTestsMocks.getMockPrimerApiConfiguration(clientSession: clientSession) -// successApiConfiguration.paymentMethods?[0].baseLogoImage = PrimerTheme.BaseImage(colored: UIImage(), light: nil, dark: nil) -// setupPrimerConfiguration(paymentMethod: Mocks.PaymentMethods.klarnaPaymentMethod, apiConfiguration: successApiConfiguration) -// } -// -// private func restartPrimerConfiguration() { -// AppState.current.clientToken = nil -// PrimerAPIConfigurationModule.clientToken = nil -// PrimerAPIConfigurationModule.apiConfiguration = nil -// PrimerAPIConfigurationModule.apiClient = nil -// tokenizationComponent = nil -// } -// -// private func getInvalidTokenError() -> PrimerError { -// let error = PrimerError.invalidClientToken( -// userInfo: self.getErrorUserInfo(), -// diagnosticsId: UUID().uuidString -// ) -// ErrorHandler.handle(error: error) -// return error -// } -// -// private func getErrorUserInfo() -> [String: String] { -// return [ -// "file": #file, -// "class": "\(Self.self)", -// "function": #function, -// "line": "\(#line)" -// ] -// } -// } +extension KlarnaTokenizationManagerTests { + private func setupPrimerConfiguration(paymentMethod: PrimerPaymentMethod, apiConfiguration: PrimerAPIConfiguration) { + let mockApiClient = MockPrimerAPIClient() + mockApiClient.fetchConfigurationWithActionsResult = (apiConfiguration, nil) + mockApiClient.mockSuccessfulResponses() + + AppState.current.clientToken = KlarnaTestsMocks.clientToken + PrimerAPIConfigurationModule.apiClient = mockApiClient + PrimerAPIConfigurationModule.clientToken = KlarnaTestsMocks.clientToken + PrimerAPIConfigurationModule.apiConfiguration = apiConfiguration + + tokenizationManager = MockKlarnaTokenizationManager() + } + + private func prepareConfigurations() { + PrimerInternal.shared.intent = .checkout + let clientSession = KlarnaTestsMocks.getClientSession() + let successApiConfiguration = KlarnaTestsMocks.getMockPrimerApiConfiguration(clientSession: clientSession) + successApiConfiguration.paymentMethods?[0].baseLogoImage = PrimerTheme.BaseImage(colored: UIImage(), light: nil, dark: nil) + setupPrimerConfiguration(paymentMethod: Mocks.PaymentMethods.klarnaPaymentMethod, apiConfiguration: successApiConfiguration) + } + + private func restartPrimerConfiguration() { + AppState.current.clientToken = nil + PrimerAPIConfigurationModule.clientToken = nil + PrimerAPIConfigurationModule.apiConfiguration = nil + PrimerAPIConfigurationModule.apiClient = nil + tokenizationManager = nil + } + + private func getInvalidTokenError() -> PrimerError { + let error = PrimerError.invalidClientToken( + userInfo: self.getErrorUserInfo(), + diagnosticsId: UUID().uuidString + ) + ErrorHandler.handle(error: error) + return error + } + + private func getErrorUserInfo() -> [String: String] { + return [ + "file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)" + ] + } +} + +class MockKlarnaTokenizationManager: KlarnaTokenizationManagerProtocol { + var mockedSuccessValue: Bool = false + + let primerError = PrimerError.paymentFailed(paymentMethodType: "KLARNA", description: "payment_failed", userInfo: nil, diagnosticsId: UUID().uuidString) + + func tokenizeHeadless(customerToken: PrimerSDK.Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> PrimerSDK.Promise { + return Promise { seal in + + let primerCheckoutData = PrimerCheckoutData(payment: PrimerCheckoutDataPayment(id: "mock-id", orderId: "ios-mock-id", paymentFailureReason: nil)) + + mockedSuccessValue ? seal.fulfill(primerCheckoutData) : seal.reject(primerError) + } + } + + func tokenizeDropIn(customerToken: PrimerSDK.Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> PrimerSDK.Promise { + return Promise { seal in + + let tokenData = KlarnaTestsMocks.primerPaymentMethodTokenData + mockedSuccessValue ? seal.fulfill(tokenData) : seal.reject(primerError) + } + } +} #endif diff --git a/Debug App/Tests/Unit Tests/Primer/Klarna/PrimerKlarnaCategoriesViewControllerTests.swift b/Debug App/Tests/Unit Tests/Primer/Klarna/PrimerKlarnaCategoriesViewControllerTests.swift new file mode 100644 index 0000000000..d7c3b058f2 --- /dev/null +++ b/Debug App/Tests/Unit Tests/Primer/Klarna/PrimerKlarnaCategoriesViewControllerTests.swift @@ -0,0 +1,69 @@ +// +// PrimerKlarnaCategoriesViewControllerTests.swift +// Debug App Tests +// +// Created by Stefan Vrancianu on 18.03.2024. +// Copyright © 2024 Primer API Ltd. All rights reserved. +// + +#if canImport(PrimerKlarnaSDK) +import XCTest +@testable import PrimerSDK + +final class PrimerKlarnaCategoriesViewControllerTests: XCTestCase { + + var sut: PrimerKlarnaCategoriesViewController! + var mockDelegate: MockPrimerKlarnaCategoriesDelegate! + + override func setUp() { + super.setUp() + mockDelegate = MockPrimerKlarnaCategoriesDelegate() + let paymentMethod = Mocks.PaymentMethods.klarnaPaymentMethod + let tokenizationComponent = KlarnaTokenizationComponent(paymentMethod: paymentMethod) + sut = PrimerKlarnaCategoriesViewController(tokenizationComponent: tokenizationComponent, delegate: mockDelegate) + sut.loadViewIfNeeded() + } + + override func tearDown() { + mockDelegate = nil + sut = nil + super.tearDown() + } + + func test_sessionCompleted() { + let authToken = "auth-token" + sut.sessionFinished(with: authToken) + + XCTAssertEqual(mockDelegate.sessionCompleted, true) + XCTAssertEqual(mockDelegate.authorizationTokenReceived, authToken) + } + + func test_sessionFailed() { + let error = PrimerError.failedToCreateSession(error: nil, userInfo: [:], diagnosticsId: UUID().uuidString) + sut.didReceiveError(error: error) + + let errorReceived = mockDelegate.errorReceived as? PrimerError + + XCTAssertEqual(mockDelegate.sessionFailed, true) + XCTAssertEqual(errorReceived?.diagnosticsId, error.diagnosticsId) + } +} + +class MockPrimerKlarnaCategoriesDelegate: PrimerKlarnaCategoriesDelegate { + var sessionCompleted = false + var sessionFailed = false + var authorizationTokenReceived: String? + var errorReceived: Error? + + func primerKlarnaPaymentSessionCompleted(authorizationToken: String) { + sessionCompleted = true + authorizationTokenReceived = authorizationToken + } + + func primerKlarnaPaymentSessionFailed(error: Error) { + sessionFailed = true + errorReceived = error + } +} +#endif + diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionAuthorization.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionAuthorization.swift index 1df6756941..790b59ffdc 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionAuthorization.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionAuthorization.swift @@ -27,7 +27,13 @@ extension PrimerHeadlessKlarnaComponent { #endif if isMocked { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.finalizeSession(token: UUID().uuidString, fromAuthorization: true) + if PrimerInternal.shared.sdkIntegrationType == .headless { + self.finalizeSession(token: UUID().uuidString, fromAuthorization: true) + } else { + let checkoutData = PrimerCheckoutData(payment: nil) + let step = KlarnaStep.paymentSessionAuthorized(authToken: UUID().uuidString, checkoutData: checkoutData) + self.stepDelegate?.didReceiveStep(step: step) + } } } else { var extraMerchantDataString: String? @@ -66,7 +72,13 @@ extension PrimerHeadlessKlarnaComponent: PrimerKlarnaProviderAuthorizationDelega } } if let authToken = authToken, approved == true { - finalizeSession(token: authToken, fromAuthorization: true) + if PrimerInternal.shared.sdkIntegrationType == .headless { + finalizeSession(token: authToken, fromAuthorization: true) + } else { + let checkoutData = PrimerCheckoutData(payment: nil) + let step = KlarnaStep.paymentSessionAuthorized(authToken: authToken, checkoutData: checkoutData) + self.stepDelegate?.didReceiveStep(step: step) + } } if finalizeRequired == true { let step = KlarnaStep.paymentSessionFinalizationRequired diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionFinalization.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionFinalization.swift index 10a2b22869..8bd589b323 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionFinalization.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent+SessionFinalization.swift @@ -29,7 +29,13 @@ extension PrimerHeadlessKlarnaComponent: PrimerKlarnaProviderFinalizationDelegat createSessionError(.klarnaFinalizationFailed) } if let authToken = authToken, approved == true { - finalizeSession(token: authToken, fromAuthorization: false) + if PrimerInternal.shared.sdkIntegrationType == .headless { + finalizeSession(token: authToken, fromAuthorization: false) + } else { + let checkoutData = PrimerCheckoutData(payment: nil) + let step = KlarnaStep.paymentSessionFinalized(authToken: authToken, checkoutData: checkoutData) + self.stepDelegate?.didReceiveStep(step: step) + } } } } diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent.swift index 82eb5e2901..c9b1433a5b 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Components/PrimerHeadlessKlarnaComponent.swift @@ -24,15 +24,18 @@ class PrimerHeadlessKlarnaComponent { public weak var stepDelegate: PrimerHeadlessSteppableDelegate? public weak var validationDelegate: PrimerHeadlessValidatableDelegate? public internal(set) var nextDataStep: KlarnaStep = .notLoaded + // MARK: - Init init(tokenizationComponent: KlarnaTokenizationComponentProtocol) { self.tokenizationComponent = tokenizationComponent } + func setPaymentSessionDelegates() { setAuthorizationDelegate() setFinalizationDelegate() setPaymentViewDelegate() } + /// Configures the Klarna provider and view handling component with necessary information for payment processing. func setProvider(with clientToken: String, paymentCategory: String) { let provider: PrimerKlarnaProviding = PrimerKlarnaProvider(clientToken: clientToken, @@ -40,6 +43,7 @@ class PrimerHeadlessKlarnaComponent { urlScheme: settings.paymentMethodOptions.urlScheme) klarnaProvider = provider } + /// Validates the tokenization component, handling any errors that occur during the process. func validate() { do { @@ -50,6 +54,7 @@ class PrimerHeadlessKlarnaComponent { } } } + func resetKlarnaSessionVariables() { isFinalizationRequired = false availableCategories = [] @@ -69,6 +74,7 @@ extension PrimerHeadlessKlarnaComponent: KlarnaComponent { finalizePayment() } } + func validateData(for data: KlarnaCollectableData) { validationDelegate?.didUpdate(validationStatus: .validating, for: data) switch data { @@ -106,10 +112,12 @@ extension PrimerHeadlessKlarnaComponent: KlarnaComponent { } } } + public func submit() { trackSubmit() authorizeSession() } + /// Initiates the creation of a Klarna payment session. public func start() { validate() @@ -136,7 +144,7 @@ extension PrimerHeadlessKlarnaComponent { tokenizationComponent.authorizePaymentSession(authorizationToken: token) } .then { customerToken in - self.tokenizationComponent.tokenize(customerToken: customerToken, offSessionAuthorizationId: token) + self.tokenizationComponent.tokenizeHeadless(customerToken: customerToken, offSessionAuthorizationId: token) } .done { checkoutData in if fromAuthorization { @@ -178,6 +186,7 @@ extension PrimerHeadlessKlarnaComponent: PrimerHeadlessAnalyticsRecordable { params: [:] ) } + func trackSubmit() { recordEvent( type: .sdkEvent, @@ -185,6 +194,7 @@ extension PrimerHeadlessKlarnaComponent: PrimerHeadlessAnalyticsRecordable { params: [:] ) } + func trackCollectableData() { recordEvent( type: .sdkEvent, diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Managers/KlarnaTokenizationManager.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Managers/KlarnaTokenizationManager.swift index 567c7ef2d4..40935e02ab 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Managers/KlarnaTokenizationManager.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Managers/KlarnaTokenizationManager.swift @@ -17,53 +17,28 @@ protocol KlarnaTokenizationManagerProtocol { - Returns: A `Promise` which resolves to a `PrimerPaymentMethodTokenData` object on successful tokenization or rejects with an `Error` if the tokenization process fails. */ - func tokenize(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise + func tokenizeHeadless(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise + func tokenizeDropIn(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise } class KlarnaTokenizationManager: KlarnaTokenizationManagerProtocol { + // MARK: - Properties private let tokenizationService: TokenizationServiceProtocol + // MARK: - Init init() { self.tokenizationService = TokenizationService() } - // MARK: - Tokenize - func tokenize(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise { + + // MARK: - Tokenize Headless + func tokenizeHeadless(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise { return Promise { seal in - var customerTokenId: String? - var paymentInstrument: TokenizationRequestBodyPaymentInstrument - // Validates the presence of session data. - // If the session data is missing, it generates an error indicating an invalid value for `tokenization.sessionData` - guard let sessionData = customerToken?.sessionData else { - let error = KlarnaHelpers.getInvalidValueError(key: "tokenization.sessionData", value: nil) - seal.reject(error) - return - } - // Checks if the session type is for recurring payments. If so, it attempts to extract the - // customer token ID and sets 'KlarnaCustomerTokenPaymentInstrument' as a payment instrument. - // Otherwise it sets the 'customerTokenId' with 'offSessionAuthorizationId' value - // which is 'authToken' returned from 'primerKlarnaWrapperFinalized' KlarnaProvider - // delegate method and sets 'KlarnaAuthorizationPaymentInstrument' as a payment instrument. - // If the token ID is not found, it generates an error indicating an invalid value - // for `tokenization.customerToken` - if KlarnaHelpers.getSessionType() == .recurringPayment { - guard let klarnaCustomerToken = customerToken?.customerTokenId else { - let error = KlarnaHelpers.getInvalidValueError(key: "tokenization.customerToken", value: nil) - seal.reject(error) - return - } - customerTokenId = klarnaCustomerToken - // Prepares the payment instrument by creating a `KlarnaCustomerTokenPaymentInstrument` object - paymentInstrument = KlarnaCustomerTokenPaymentInstrument(klarnaCustomerToken: customerTokenId, sessionData: sessionData) - } else { - customerTokenId = offSessionAuthorizationId - // Prepares the payment instrument by creating a `KlarnaCustomerTokenPaymentInstrument` object - paymentInstrument = KlarnaAuthorizationPaymentInstrument(klarnaAuthorizationToken: customerTokenId, sessionData: sessionData) - } - // Constructs a request body with the payment instrument and initiates a tokenization request through the `tokenizationService`. - let requestBody = Request.Body.Tokenization(paymentInstrument: paymentInstrument) firstly { - tokenizationService.tokenize(requestBody: requestBody) + getRequestBody(customerToken: customerToken, offSessionAuthorizationId: offSessionAuthorizationId) + } + .then { requestBody in + self.tokenizationService.tokenize(requestBody: requestBody) } .then { paymentMethodTokenData in self.startPaymentFlow(with: paymentMethodTokenData) @@ -76,6 +51,24 @@ class KlarnaTokenizationManager: KlarnaTokenizationManagerProtocol { } } } + + // MARK: - Tokenize DropIn + func tokenizeDropIn(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise { + return Promise { seal in + firstly { + getRequestBody(customerToken: customerToken, offSessionAuthorizationId: offSessionAuthorizationId) + } + .then { requestBody in + self.tokenizationService.tokenize(requestBody: requestBody) + } + .done { paymentMethodTokenData in + seal.fulfill(paymentMethodTokenData) + } + .catch { error in + seal.reject(error) + } + } + } } extension KlarnaTokenizationManager { @@ -97,6 +90,7 @@ extension KlarnaTokenizationManager { } } } + // Create payment with Payment method token private func createPaymentEvent(_ paymentMethodData: String) -> Promise { return Promise { seal in @@ -119,4 +113,42 @@ extension KlarnaTokenizationManager { } } } + + private func getRequestBody(customerToken: Response.Body.Klarna.CustomerToken?, offSessionAuthorizationId: String?) -> Promise { + return Promise { seal in + var customerTokenId: String? + var paymentInstrument: TokenizationRequestBodyPaymentInstrument + // Validates the presence of session data. + // If the session data is missing, it generates an error indicating an invalid value for `tokenization.sessionData` + guard let sessionData = customerToken?.sessionData else { + let error = KlarnaHelpers.getInvalidValueError(key: "tokenization.sessionData", value: nil) + seal.reject(error) + return + } + // Checks if the session type is for recurring payments. If so, it attempts to extract the + // customer token ID and sets 'KlarnaCustomerTokenPaymentInstrument' as a payment instrument. + // Otherwise it sets the 'customerTokenId' with 'offSessionAuthorizationId' value + // which is 'authToken' returned from 'primerKlarnaWrapperFinalized' KlarnaProvider + // delegate method and sets 'KlarnaAuthorizationPaymentInstrument' as a payment instrument. + // If the token ID is not found, it generates an error indicating an invalid value + // for `tokenization.customerToken` + if KlarnaHelpers.getSessionType() == .recurringPayment { + guard let klarnaCustomerToken = customerToken?.customerTokenId else { + let error = KlarnaHelpers.getInvalidValueError(key: "tokenization.customerToken", value: nil) + seal.reject(error) + return + } + customerTokenId = klarnaCustomerToken + // Prepares the payment instrument by creating a `KlarnaCustomerTokenPaymentInstrument` object + paymentInstrument = KlarnaCustomerTokenPaymentInstrument(klarnaCustomerToken: customerTokenId, sessionData: sessionData) + } else { + customerTokenId = offSessionAuthorizationId + // Prepares the payment instrument by creating a `KlarnaCustomerTokenPaymentInstrument` object + paymentInstrument = KlarnaAuthorizationPaymentInstrument(klarnaAuthorizationToken: customerTokenId, sessionData: sessionData) + } + // Constructs a request body with the payment instrument and initiates a tokenization request through the `tokenizationService`. + let requestBody = Request.Body.Tokenization(paymentInstrument: paymentInstrument) + seal.fulfill(requestBody) + } + } } diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaHelpers.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaHelpers.swift index f23b0ce3d0..fc66ccd3df 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaHelpers.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaHelpers.swift @@ -9,15 +9,18 @@ import Foundation // KlarnaHelpers: A utility structure to facilitate various operations related to Klarna payment sessions. struct KlarnaHelpers { + enum AddressType { case shipping case billing } + struct KlarnaPaymentSessionParams { var paymentMethodConfigId: String var sessionId: String var decodedJWTToken: DecodedJWTToken } + /// - Returns the session type based on the current payment intent (vault or checkout). static func getSessionType() -> KlarnaSessionType { if PrimerInternal.shared.intent == .vault { @@ -26,6 +29,7 @@ struct KlarnaHelpers { return .oneOffPayment } } + /// - Constructs the request body for finalize a Klarna payment session /// - Returns: An instance of Request.Body.Klarna.FinalizePaymentSession static func getKlarnaFinalizePaymentBody( @@ -36,6 +40,7 @@ struct KlarnaHelpers { paymentMethodConfigId: paymentMethodConfigId, sessionId: sessionId) } + /// - Constructs the request body for creating a Klarna customer token. /// - Returns: An instance of Request.Body.Klarna.CreateCustomerToken static func getKlarnaCustomerTokenBody( @@ -51,6 +56,7 @@ struct KlarnaHelpers { description: recurringPaymentDescription, localeData: PrimerSettings.current.localeData) } + /// - Prepares the request body for creating a Klarna payment session. /// - Returns: An instance of Request.Body.Klarna.CreatePaymentSession static func getKlarnaPaymentSessionBody( @@ -80,6 +86,7 @@ struct KlarnaHelpers { description = recurringPaymentDescription redUrl = redirectUrl } + return Request.Body.Klarna.CreatePaymentSession( paymentMethodConfigId: paymentMethodConfigId, sessionType: sessionType, @@ -91,6 +98,7 @@ struct KlarnaHelpers { billingAddress: billingAddress, shippingAddress: shippingAddress) } + /// - Returns a customer's address, either billing or shipping, based on the specified type. static func getCustomerAddress(of type: AddressType, clientSession: ClientSession.APIResponse?) -> Response.Body.Klarna.BillingAddress { let billingAddress = clientSession?.customer?.billingAddress @@ -111,6 +119,7 @@ struct KlarnaHelpers { state: type == .billing ? billingAddress?.state : shippingAddress?.state, title: nil) } + /// - Converts a 'ClientSession.Order.LineItem' from the client session into a 'Request.Body.Klarna.OrderItem'. /// - Returns: An instance of Request.Body.Klarna.OrderItem static func getOrderItem(from item: ClientSession.Order.LineItem) -> Request.Body.Klarna.OrderItem { @@ -123,6 +132,7 @@ struct KlarnaHelpers { productType: item.productType, taxAmount: item.taxAmount ?? 0) } + /// - Adds a surcharge item to the list of order items if applicable. /// - Returns an array of Request.Body.Klarna.OrderItem static func addedSurchargeItem(to list: [Request.Body.Klarna.OrderItem], surcharge: Int?) -> [Request.Body.Klarna.OrderItem] { @@ -139,6 +149,7 @@ struct KlarnaHelpers { orderList.append(surchargeItem) return orderList } + /// - Returns the surcharge value from the order fees if any static func getSurcharge(fees: [ClientSession.Order.Fee]?) -> Int? { if let fees { @@ -146,6 +157,7 @@ struct KlarnaHelpers { } return nil } + /// - Returns the serialized string value of the attachment data static func getSerializedAttachmentString(from extraMerchantData: [String: Any]) -> String? { let dict = ["contentType": "application/vnd.klarna.internal.emd-v2+json", @@ -157,6 +169,7 @@ struct KlarnaHelpers { return nil } } + /// - Helper function to construct locale data. private static func constructLocaleData(using clientSession: ClientSession.APIResponse?) -> Request.Body.Klarna.KlarnaLocaleData { let countryCode = clientSession?.order?.countryCode?.rawValue ?? "" @@ -167,6 +180,7 @@ struct KlarnaHelpers { currencyCode: currencyCode, localeCode: localeCode) } + // MARK: - Error helpers static func getInvalidTokenError() -> PrimerError { let error = PrimerError.invalidClientToken( @@ -176,6 +190,7 @@ struct KlarnaHelpers { ErrorHandler.handle(error: error) return error } + static func getInvalidSettingError( name: String ) -> PrimerError { @@ -188,6 +203,7 @@ struct KlarnaHelpers { ErrorHandler.handle(error: error) return error } + static func getInvalidValueError( key: String, value: Any? = nil @@ -201,6 +217,7 @@ struct KlarnaHelpers { ErrorHandler.handle(error: error) return error } + static func getPaymentFailedError() -> PrimerError { let error = PrimerError.paymentFailed( paymentMethodType: "KLARNA", @@ -210,6 +227,7 @@ struct KlarnaHelpers { ErrorHandler.handle(error: error) return error } + static func getFailedToProcessPaymentError(paymentResponse: Response.Body.Payment) -> PrimerError { let error = PrimerError.failedToProcessPayment( paymentMethodType: "KLARNA", @@ -225,6 +243,32 @@ struct KlarnaHelpers { ErrorHandler.handle(error: error) return error } + + static func getInvalidUrlSchemeError(settings: PrimerSettingsProtocol) -> PrimerError { + let error = PrimerError.invalidUrlScheme( + urlScheme: settings.paymentMethodOptions.urlScheme, + userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: error) + return error + } + + static func getMissingSDKError() -> PrimerError { + let error = PrimerError.missingSDK( + paymentMethodType: PrimerPaymentMethodType.klarna.rawValue, + sdkName: "KlarnaSDK", + userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: error) + return error + } + static func getErrorUserInfo() -> [String: String] { return [ "file": #file, diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaPaymentCategory.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaPaymentCategory.swift index 65da850947..4b1af11ee4 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaPaymentCategory.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/Klarna/Models/KlarnaPaymentCategory.swift @@ -20,6 +20,7 @@ public struct KlarnaPaymentCategory: Codable { self.standardAssetUrl = response.standardAssetUrl } } + extension KlarnaPaymentCategory: Equatable { public static func == (lhs: KlarnaPaymentCategory, rhs: KlarnaPaymentCategory) -> Bool { return lhs.id == rhs.id && diff --git a/Sources/PrimerSDK/Classes/Data Models/PaymentMethod.swift b/Sources/PrimerSDK/Classes/Data Models/PaymentMethod.swift index 6ba8ffaa63..113a1d2564 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PaymentMethod.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PaymentMethod.swift @@ -109,7 +109,9 @@ class PrimerPaymentMethod: Codable, LogReporter { return ApplePayTokenizationViewModel(config: self) case PrimerPaymentMethodType.klarna: - return KlarnaTokenizationViewModel(config: self) + if #available(iOS 13.0, *) { + return KlarnaTokenizationViewModel(config: self) + } case PrimerPaymentMethodType.paymentCard, PrimerPaymentMethodType.adyenBancontactCard: diff --git a/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesElements.swift b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesElements.swift new file mode 100644 index 0000000000..54671166b6 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesElements.swift @@ -0,0 +1,123 @@ +// +// PrimerKlarnaCategoriesElements.swift +// PrimerSDK +// +// Created by Stefan Vrancianu on 08.03.2024. +// + +import UIKit +import SwiftUI + +class SharedUIViewWrapper: ObservableObject { + @Published var uiView: UIView? +} + +struct DynamicUIViewRepresentable: UIViewRepresentable { + @ObservedObject var wrapper: SharedUIViewWrapper + + func makeUIView(context: Context) -> UIView { + return wrapper.uiView ?? UIView() + } + + func updateUIView(_ uiView: UIView, context: Context) { + uiView.subviews.forEach { $0.removeFromSuperview() } + + if let newView = wrapper.uiView { + if newView.superview == nil { + uiView.addSubview(newView) + newView.frame = uiView.bounds + } + } + } +} + +struct KlarnaCategoryButton: View { + + @ObservedObject var sharedWrapper: SharedUIViewWrapper + + var isSelected: Bool + let title: String + let action: () -> Void + let klarnaCategoryImage = UIImage(named: "klarna_payment_category", in: Bundle.primerResources, compatibleWith: nil) ?? UIImage() + let checkmarkImage = UIImage(named: "check2", in: Bundle.primerResources, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + + var body: some View { + VStack { + HStack { + Image(uiImage: klarnaCategoryImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + Text(title) + .foregroundColor(.black) + .fontWeight(.medium) + Spacer() + if isSelected { + Image(uiImage: checkmarkImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 15, height: 15) + .padding(5) + .foregroundColor(.blue) + } + } + .background(GeometryReader { geometry in + Color.clear + .frame(width: geometry.size.width, height: geometry.size.height) + .background(Color.red.opacity(0.001)) + .onTapGesture { + withAnimation { + action() + } + } + }) + + if isSelected { + DynamicUIViewRepresentable(wrapper: sharedWrapper) + .frame(height: 240) + .addAccessibilityIdentifier(identifier: AccessibilityIdentifier.KlarnaComponent.paymentViewContainer.rawValue) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.white) + .cornerRadius(5) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(isSelected ? Color.blue.opacity(0.8) : Color.gray.opacity(0.25), lineWidth: isSelected ? 2 : 1) + ) + } +} + +struct ContinueButton: View { + @Binding var isActive: Bool + + let title: String + let continuePressed: () -> Void + + var body: some View { + Button { + continuePressed() + } label: { + Text(title) + .font(.headline) + .foregroundColor(isActive ? .white : .black.opacity(0.2)) + .frame(maxWidth: .infinity) + .padding() + .background(isActive ? Color.blue : Color.gray.opacity(0.2)) + .cornerRadius(5) + } + .disabled(!isActive) + } +} + +extension View { + @ViewBuilder func addAccessibilityIdentifier(identifier: String) -> some View { + if #available(iOS 14.0, *) { + accessibilityIdentifier(identifier) + } else { + accessibility(identifier: identifier) + } + } +} diff --git a/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesView.swift b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesView.swift new file mode 100644 index 0000000000..5786055d40 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesView.swift @@ -0,0 +1,77 @@ +// +// PrimerKlarnaCategoriesView.swift +// PrimerSDK +// +// Created by Stefan Vrancianu on 08.03.2024. +// + +import Foundation +import SwiftUI + +struct PrimerKlarnaCategoriesView: View { + @ObservedObject var viewModel = PrimerKlarnaCategoriesViewModel() + @ObservedObject var sharedWrapper: SharedUIViewWrapper + @State private var selectedCategory: KlarnaPaymentCategory? + @State private var isButtonActive: Bool = false + + var onBackPressed: () -> Void + var onInitializePressed: (KlarnaPaymentCategory?) -> Void + var onContinuePressed: () -> Void + let klarnaLogoImage = UIImage(named: "klarna-logo-colored", in: Bundle.primerResources, compatibleWith: nil) ?? UIImage() + let leftArrowImage = UIImage(named: "arrow-left", in: Bundle.primerResources, compatibleWith: nil) ?? UIImage() + + var body: some View { + + ZStack { + Button { + onBackPressed() + } label: { + HStack { + Image(uiImage: leftArrowImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25, height: 25) + .padding(.leading, 15) + + Spacer() + } + .opacity(viewModel.showBackButton ? 1 : 0) + } + + Image(uiImage: klarnaLogoImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 66, height: 33) + } + .padding(.top, -8) + + VStack(alignment: .leading, spacing: 10) { + ForEach(viewModel.paymentCategories, id: \.id) { category in + KlarnaCategoryButton(sharedWrapper: sharedWrapper, isSelected: selectedCategory?.id == category.id, title: category.name) { + selectedCategory = selectedCategory?.id == category.id ? nil : category + isButtonActive = selectedCategory != nil + onInitializePressed(selectedCategory) + } + .addAccessibilityIdentifier(identifier: AccessibilityIdentifier.KlarnaComponent.initializeView.rawValue) + } + Spacer() + } + .frame(height: 450) + .padding() + .disabled(viewModel.shouldDisableKlarnaViews) + .opacity(viewModel.isAuthorizing ? 0 : 1) + + Spacer() + + HStack { + ContinueButton(isActive: $isButtonActive, title: "Continue") { + onContinuePressed() + viewModel.shouldDisableKlarnaViews = true + } + .addAccessibilityIdentifier(identifier: AccessibilityIdentifier.KlarnaComponent.authorize.rawValue) + } + .padding(.horizontal, 15) + .disabled(viewModel.shouldDisableKlarnaViews) + .opacity(viewModel.showBackButton ? 1 : 0) + } +} diff --git a/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewController.swift new file mode 100644 index 0000000000..cbeef86282 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewController.swift @@ -0,0 +1,221 @@ +// +// PrimerKlarnaCategoriesSheet.swift +// PrimerSDK +// +// Created by Stefan Vrancianu on 08.03.2024. +// + +import UIKit +import SwiftUI +#if canImport(PrimerKlarnaSDK) +import PrimerKlarnaSDK + +protocol PrimerKlarnaCategoriesDelegate: AnyObject { + func primerKlarnaPaymentSessionCompleted(authorizationToken: String) + func primerKlarnaPaymentSessionFailed(error: Error) +} + +class PrimerKlarnaCategoriesViewController: UIViewController { + + // MARK: - Subviews + let activityIndicator = UIActivityIndicatorView() + + // MARK: - Properties + let klarnaCategoriesVM: PrimerKlarnaCategoriesViewModel = PrimerKlarnaCategoriesViewModel() + var klarnaCategoriesView: PrimerKlarnaCategoriesView? + let sharedWrapper = SharedUIViewWrapper() + var renderedKlarnaView = UIView() + var clientToken: String? + var klarnaComponent: PrimerHeadlessKlarnaComponent + weak var delegate: PrimerKlarnaCategoriesDelegate? + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + init(tokenizationComponent: KlarnaTokenizationComponentProtocol, delegate: PrimerKlarnaCategoriesDelegate) { + self.klarnaComponent = PrimerHeadlessKlarnaComponent(tokenizationComponent: tokenizationComponent) + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + setKlarnaComponentDelegates() + setupUI() + setupLayout() + addKlarnaView() + startPaymentSession() + } + + private func setKlarnaComponentDelegates() { + klarnaComponent.stepDelegate = self + klarnaComponent.errorDelegate = self + klarnaComponent.validationDelegate = self + } + + private func addKlarnaView() { + klarnaCategoriesView = PrimerKlarnaCategoriesView(viewModel: klarnaCategoriesVM, sharedWrapper: sharedWrapper) { + self.navigationController?.popViewController(animated: false) + } onInitializePressed: { paymentCategory in + guard let paymentCategory = paymentCategory else { return } + let klarnaCollectableData = KlarnaCollectableData.paymentCategory(paymentCategory, clientToken: self.clientToken) + self.klarnaComponent.updateCollectedData(collectableData: klarnaCollectableData) + } onContinuePressed: { + self.authorizeSession() + } + + let hostingViewController = UIHostingController(rootView: klarnaCategoriesView) + hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(hostingViewController) + view.addSubview(hostingViewController.view) + hostingViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + hostingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor), + hostingViewController.view.heightAnchor.constraint( + equalTo: view.heightAnchor, + multiplier: 1 + ) + ]) + } + + func passRenderedKlarnaView(_ renderedKlarnaView: UIView) { + sharedWrapper.uiView = renderedKlarnaView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let parentVC = self.parent as? PrimerContainerViewController { + parentVC.mockedNavigationBar.hidesBackButton = true + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let parentVC = self.parent as? PrimerContainerViewController { + parentVC.mockedNavigationBar.hidesBackButton = false + } + } +} + +// MARK: - Setup UI +extension PrimerKlarnaCategoriesViewController { + func setupUI() { + view.backgroundColor = .white + activityIndicator.hidesWhenStopped = true + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + } + + func setupLayout() { + view.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Helpers +extension PrimerKlarnaCategoriesViewController { + func showLoader() { + view.bringSubviewToFront(activityIndicator) + activityIndicator.startAnimating() + } + + func hideLoader() { + activityIndicator.stopAnimating() + } + + func showLoadingState() { + klarnaCategoriesVM.isAuthorizing = true + klarnaCategoriesVM.showBackButton = false + showLoader() + } +} + +extension PrimerKlarnaCategoriesViewController: PrimerHeadlessErrorableDelegate, + PrimerHeadlessValidatableDelegate, + PrimerHeadlessSteppableDelegate { + // MARK: - PrimerHeadlessErrorableDelegate + func didReceiveError(error: PrimerSDK.PrimerError) { + showLoadingState() + delegate?.primerKlarnaPaymentSessionFailed(error: error) + } + + // MARK: - PrimerHeadlessValidatableDelegate + func didUpdate(validationStatus: PrimerSDK.PrimerValidationStatus, for data: PrimerSDK.PrimerCollectableData?) { + switch validationStatus { + case .validating: + showLoader() + case .valid: + hideLoader() + case .invalid(errors: let errors): + hideLoader() + if let error = errors.first { + showLoadingState() + delegate?.primerKlarnaPaymentSessionFailed(error: error) + } + case .error(error: let error): + hideLoader() + showLoadingState() + delegate?.primerKlarnaPaymentSessionFailed(error: error) + } + } + + // MARK: - PrimerHeadlessSteppableDelegate + func didReceiveStep(step: PrimerSDK.PrimerHeadlessStep) { + if let step = step as? KlarnaStep { + switch step { + case .paymentSessionCreated(let clientToken, let paymentCategories): + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.hideLoader() + self.clientToken = clientToken + self.klarnaCategoriesVM.updatePaymentCategories(paymentCategories) + } + + case .paymentSessionFinalizationRequired: + finalizeSession() + + case .paymentSessionAuthorized(let authToken, _), .paymentSessionFinalized( let authToken, _): + sessionFinished(with: authToken) + + case .viewLoaded(let view): + hideLoader() + if let view { + passRenderedKlarnaView(view) + } + + default: + break + } + } + } +} + +// MARK: - Payment +extension PrimerKlarnaCategoriesViewController { + func sessionFinished(with authToken: String) { + showLoadingState() + delegate?.primerKlarnaPaymentSessionCompleted(authorizationToken: authToken) + } + + func startPaymentSession() { + showLoader() + klarnaComponent.start() + } + + func authorizeSession() { + klarnaComponent.submit() + } + + func finalizeSession() { + showLoader() + klarnaComponent.updateCollectedData(collectableData: KlarnaCollectableData.finalizePayment) + } +} +#endif diff --git a/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewModel.swift b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewModel.swift new file mode 100644 index 0000000000..719f46c016 --- /dev/null +++ b/Sources/PrimerSDK/Classes/User Interface/Klarna Categories Sheet/PrimerKlarnaCategoriesViewModel.swift @@ -0,0 +1,23 @@ +// +// PrimerKlarnaCategoriesViewModel.swift +// PrimerSDK +// +// Created by Stefan Vrancianu on 08.03.2024. +// + +import SwiftUI +#if canImport(PrimerKlarnaSDK) +import PrimerKlarnaSDK +#endif + +class PrimerKlarnaCategoriesViewModel: ObservableObject { + @Published var paymentCategories: [KlarnaPaymentCategory] = [] + @Published var showBackButton: Bool = false + @Published var isAuthorizing: Bool = false + @Published var shouldDisableKlarnaViews: Bool = false + + func updatePaymentCategories(_ paymentCategories: [KlarnaPaymentCategory]) { + self.paymentCategories = paymentCategories + self.showBackButton = true + } +} diff --git a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/KlarnaTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/KlarnaTokenizationViewModel.swift index 39a68a63fb..119ac09385 100644 --- a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/KlarnaTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/KlarnaTokenizationViewModel.swift @@ -17,102 +17,20 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { var willDismissExternalView: (() -> Void)? var didDismissExternalView: (() -> Void)? - #if canImport(PrimerKlarnaSDK) - private var klarnaViewController: PrimerKlarnaViewController? - #endif - - #if DEBUG - private var demoThirdPartySDKViewController: PrimerThirdPartySDKViewController? - #endif + private let settings: PrimerSettingsProtocol = DependencyContainer.resolve() + private var tokenizationComponent: KlarnaTokenizationComponentProtocol private var klarnaPaymentSession: Response.Body.Klarna.PaymentSession? private var klarnaCustomerTokenAPIResponse: Response.Body.Klarna.CustomerToken? private var klarnaPaymentSessionCompletion: ((_ authorizationToken: String?, _ error: Error?) -> Void)? private var authorizationToken: String? - override func validate() throws { - guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken, decodedJWTToken.isValid else { - let err = PrimerError.invalidClientToken(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - guard decodedJWTToken.pciUrl != nil else { - let err = PrimerError.invalidClientToken(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - guard config.id != nil else { - let err = PrimerError.invalidValue(key: "configuration.id", value: config.id, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - let klarnaSessionType: KlarnaSessionType = PrimerInternal.shared.intent == .vault ? .recurringPayment : .oneOffPayment - - if PrimerInternal.shared.intent == .checkout && AppState.current.amount == nil { - let err = PrimerError.invalidSetting(name: "amount", value: nil, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - if case .oneOffPayment = klarnaSessionType { - if AppState.current.amount == nil { - let err = PrimerError.invalidSetting(name: "amount", value: nil, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - if AppState.current.currency == nil { - let err = PrimerError.invalidSetting(name: "currency", value: nil, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - - if (PrimerAPIConfigurationModule.apiConfiguration?.clientSession?.order?.lineItems ?? []).isEmpty { - let err = PrimerError.invalidValue(key: "lineItems", value: nil, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } + required init(config: PrimerPaymentMethod) { + tokenizationComponent = KlarnaTokenizationComponent(paymentMethod: config) + super.init(config: config) + } - if !(PrimerAPIConfigurationModule.apiConfiguration?.clientSession?.order?.lineItems ?? []).filter({ $0.amount == nil }).isEmpty { - let err = PrimerError.invalidValue(key: "settings.orderItems", value: nil, userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - throw err - } - } + override func validate() throws { + try tokenizationComponent.validate() } override func performPreTokenizationSteps() -> Promise { @@ -130,9 +48,7 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { ) Analytics.Service.record(event: event) - let imageView = self.uiModule.makeIconImageView(withDimension: 24.0) - PrimerUIManager.primerRootViewController?.showLoadingScreenIfNeeded(imageView: imageView, - message: nil) + PrimerUIManager.primerRootViewController?.showLoadingScreenIfNeeded(imageView: nil, message: nil) return Promise { seal in #if canImport(PrimerKlarnaSDK) @@ -147,7 +63,7 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { return self.handlePrimerWillCreatePaymentEvent(PrimerPaymentMethodData(type: self.config.type)) } .then { () -> Promise in - return self.createPaymentSession() + return self.tokenizationComponent.createPaymentSession() } .then { session -> Promise in self.klarnaPaymentSession = session @@ -157,7 +73,7 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { return self.awaitUserInput() } .then { () -> Promise in - return self.authorizePaymentSession(authorizationToken: self.authorizationToken!) + return self.tokenizationComponent.authorizePaymentSession(authorizationToken: self.authorizationToken!) } .done { klarnaCustomerTokenAPIResponse in self.klarnaCustomerTokenAPIResponse = klarnaCustomerTokenAPIResponse @@ -169,29 +85,14 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { } .ensure { self.willDismissExternalView?() - self.klarnaViewController?.dismiss(animated: true, completion: { - self.didDismissExternalView?() - }) - - #if DEBUG - self.demoThirdPartySDKViewController?.dismiss(animated: true, completion: { - self.didDismissExternalView?() - }) - #endif } .catch { err in seal.reject(err) } #else - let err = PrimerError.missingSDK(paymentMethodType: PrimerPaymentMethodType.klarna.rawValue, - sdkName: "KlarnaSDK", - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) + let error = KlarnaHelpers.getMissingSDKError() + ErrorHandler.handle(error: error) + seal.reject(error) #endif } } @@ -202,7 +103,8 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { self.checkouEventsNotifierModule.fireDidStartTokenizationEvent() } .then { () -> Promise in - return self.tokenize() + let customerToken = self.klarnaCustomerTokenAPIResponse + return self.tokenizationComponent.tokenizeDropIn(customerToken: customerToken, offSessionAuthorizationId: self.authorizationToken!) } .then { paymentMethodTokenData -> Promise in self.paymentMethodTokenData = paymentMethodTokenData @@ -226,71 +128,23 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { override func presentPaymentMethodUserInterface() -> Promise { return Promise { seal in DispatchQueue.main.async { - var isMockedBE = false - #if DEBUG - if PrimerAPIConfiguration.current?.clientSession?.testId != nil { - isMockedBE = true +#if canImport(PrimerKlarnaSDK) + guard let urlSchemeStr = self.settings.paymentMethodOptions.urlScheme, + URL(string: urlSchemeStr) != nil else { + let error = KlarnaHelpers.getInvalidUrlSchemeError(settings: self.settings) + seal.reject(error) + return } - #endif - - if !isMockedBE { - #if canImport(PrimerKlarnaSDK) - let settings: PrimerSettingsProtocol = DependencyContainer.resolve() - guard let urlSchemeStr = settings.paymentMethodOptions.urlScheme, - URL(string: urlSchemeStr) != nil else { - let err = PrimerError.invalidUrlScheme( - urlScheme: settings.paymentMethodOptions.urlScheme, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } + let categoriesViewController = PrimerKlarnaCategoriesViewController(tokenizationComponent: self.tokenizationComponent, delegate: self) - self.klarnaViewController = PrimerKlarnaViewController( - delegate: self, - paymentCategory: .payNow, - clientToken: self.klarnaPaymentSession!.clientToken, - urlScheme: urlSchemeStr) - - self.klarnaPaymentSessionCompletion = { _, err in - if let err = err { - seal.reject(err) - } else { - fatalError() - } - } - - self.willPresentExternalView?() - PrimerUIManager.primerRootViewController?.show(viewController: self.klarnaViewController!) - self.didPresentExternalView?() - seal.fulfill() - #else - seal.fulfill() - #endif - } else { - #if DEBUG - firstly { - PrimerUIManager.prepareRootViewController() - } - .done { - self.demoThirdPartySDKViewController = PrimerThirdPartySDKViewController(paymentMethodType: self.config.type) - self.demoThirdPartySDKViewController!.onSendCredentialsButtonTapped = { - self.klarnaPaymentSessionCompletion?("mock_auth_token", nil) - } - PrimerUIManager.primerRootViewController?.present(self.demoThirdPartySDKViewController!, animated: true, completion: { - seal.fulfill() - }) - } - .catch { _ in - seal.fulfill() - } - #endif - } + self.willPresentExternalView?() + PrimerUIManager.primerRootViewController?.show(viewController: categoriesViewController) + self.didPresentExternalView?() + seal.fulfill() +#else + seal.fulfill() +#endif } } } @@ -309,307 +163,16 @@ class KlarnaTokenizationViewModel: PaymentMethodTokenizationViewModel { } } } - - override func tokenize() -> Promise { - return Promise { seal in - guard let klarnaCustomerToken = self.klarnaCustomerTokenAPIResponse?.customerTokenId else { - let err = PrimerError.invalidValue(key: "tokenization.klarnaCustomerToken", - value: nil, - userInfo: nil, - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - guard let sessionData = self.klarnaCustomerTokenAPIResponse?.sessionData else { - let err = PrimerError.invalidValue(key: "tokenization.sessionData", - value: nil, - userInfo: nil, - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - let paymentInstrument = KlarnaCustomerTokenPaymentInstrument( - klarnaCustomerToken: klarnaCustomerToken, - sessionData: sessionData) - - let requestBody = Request.Body.Tokenization(paymentInstrument: paymentInstrument) - - let tokenizationService: TokenizationServiceProtocol = TokenizationService() - - firstly { - tokenizationService.tokenize(requestBody: requestBody) - } - .done { paymentMethodTokenData in - seal.fulfill(paymentMethodTokenData) - } - .catch { err in - seal.reject(err) - } - } - } - - private func createPaymentSession() -> Promise { - return Promise { seal in - guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken else { - let err = PrimerError.invalidClientToken(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - guard let configId = config.id else { - let err = PrimerError.missingPrimerConfiguration(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - let klarnaSessionType: KlarnaSessionType = PrimerInternal.shared.intent == .vault ? .recurringPayment : .oneOffPayment - - var amount = AppState.current.amount - if amount == nil && PrimerInternal.shared.intent == .checkout { - let err = PrimerError.invalidSetting(name: "amount", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - // This is not being used? - // var orderItems: [OrderItem]? = nil - - if case .oneOffPayment = klarnaSessionType { - if amount == nil { - let err = PrimerError.invalidSetting(name: "amount", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - if AppState.current.currency == nil { - let err = PrimerError.invalidSetting(name: "currency", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - if (PrimerAPIConfigurationModule.apiConfiguration?.clientSession?.order?.lineItems ?? []).isEmpty { - let err = PrimerError.invalidValue(key: "settings.orderItems", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - if !(PrimerAPIConfigurationModule.apiConfiguration?.clientSession?.order?.lineItems ?? []) - .filter({ $0.amount == nil }).isEmpty { - let err = PrimerError.invalidValue(key: "settings.orderItems.amount", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - // This is not being used? - // orderItems = PrimerAPIConfigurationModule.apiConfiguration?.clientSession?.order?.lineItems?.compactMap({ try? $0.toOrderItem() }) - // - self.logger.info(message: "Klarna amount: \(amount!) \(AppState.current.currency!.code)") - - } else if case .recurringPayment = klarnaSessionType { - // Do not send amount for recurring payments, even if it's set - amount = nil - } - - let clientSession = PrimerAPIConfigurationModule.apiConfiguration?.clientSession - let settings: PrimerSettingsProtocol = DependencyContainer.resolve() - let countryCode = clientSession?.order?.countryCode?.rawValue ?? "" - let currencyCode = clientSession?.order?.currencyCode?.code ?? "" - let localeCode = PrimerSettings.current.localeData.localeCode - let localeData = Request.Body.Klarna.KlarnaLocaleData( - countryCode: countryCode, - currencyCode: currencyCode, - localeCode: localeCode) - - let body = Request.Body.Klarna.CreatePaymentSession( - paymentMethodConfigId: configId, - sessionType: .recurringPayment, - localeData: localeData, - description: PrimerSettings.current.paymentMethodOptions.klarnaOptions?.recurringPaymentDescription, - redirectUrl: settings.paymentMethodOptions.urlScheme, - totalAmount: nil, - orderItems: nil, - billingAddress: nil, - shippingAddress: nil) - - let apiClient: PrimerAPIClientProtocol = PaymentMethodTokenizationViewModel.apiClient ?? PrimerAPIClient() - - apiClient.createKlarnaPaymentSession(clientToken: decodedJWTToken, - klarnaCreatePaymentSessionAPIRequest: body) { (result) in - switch result { - case .failure(let err): - seal.reject(err) - - case .success(let res): - self.logger.info(message: "\(res)") - seal.fulfill(res) - } - } - } - } - - private func authorizePaymentSession(authorizationToken: String) -> Promise { - return Promise { seal in - guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken else { - let err = PrimerError.invalidClientToken(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - guard let configId = PrimerAPIConfigurationModule.apiConfiguration?.getConfigId(for: PrimerPaymentMethodType.klarna.rawValue), - let sessionId = self.klarnaPaymentSession?.sessionId else { - let err = PrimerError.missingPrimerConfiguration( - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - seal.reject(err) - return - } - - let body = Request.Body.Klarna.CreateCustomerToken( - paymentMethodConfigId: configId, - sessionId: sessionId, - authorizationToken: authorizationToken, - description: PrimerSettings.current.paymentMethodOptions.klarnaOptions?.recurringPaymentDescription, - localeData: PrimerSettings.current.localeData - ) - - let apiClient: PrimerAPIClientProtocol = PaymentMethodTokenizationViewModel.apiClient ?? PrimerAPIClient() - - apiClient.createKlarnaCustomerToken(clientToken: decodedJWTToken, - klarnaCreateCustomerTokenAPIRequest: body) { (result) in - switch result { - case .failure(let err): - seal.reject(err) - case .success(let response): - seal.fulfill(response) - } - } - } - } - - private func finalizePaymentSession() -> Promise { - return Promise { seal in - self.finalizePaymentSession { result in - switch result { - case .failure(let err): - seal.reject(err) - case .success(let res): - seal.fulfill(res) - } - } - } - } - - private func finalizePaymentSession(completion: @escaping (Result) -> Void) { - guard let decodedJWTToken = PrimerAPIConfigurationModule.decodedJWTToken else { - let err = PrimerError.invalidClientToken(userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - completion(.failure(err)) - return - } - - guard let configId = PrimerAPIConfigurationModule.apiConfiguration?.getConfigId(for: PrimerPaymentMethodType.klarna.rawValue), - let sessionId = self.klarnaPaymentSession?.sessionId else { - let err = PrimerError.missingPrimerConfiguration( - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) - ErrorHandler.handle(error: err) - completion(.failure(err)) - return - } - - let body = Request.Body.Klarna.FinalizePaymentSession(paymentMethodConfigId: configId, sessionId: sessionId) - self.logger.info(message: "config ID: \(configId)") - - let apiClient: PrimerAPIClientProtocol = PaymentMethodTokenizationViewModel.apiClient ?? PrimerAPIClient() - - apiClient.finalizeKlarnaPaymentSession(clientToken: decodedJWTToken, - klarnaFinalizePaymentSessionRequest: body) { (result) in - switch result { - case .failure(let err): - completion(.failure(err)) - case .success(let response): - self.logger.info(message: "\(response)") - completion(.success(response)) - } - } - } } #if canImport(PrimerKlarnaSDK) -extension KlarnaTokenizationViewModel: PrimerKlarnaViewControllerDelegate { - - func primerKlarnaViewDidLoad() { - +extension KlarnaTokenizationViewModel: PrimerKlarnaCategoriesDelegate { + func primerKlarnaPaymentSessionCompleted(authorizationToken: String) { + klarnaPaymentSessionCompletion?(authorizationToken, nil) } - func primerKlarnaPaymentSessionCompleted(authorizationToken: String?, error: PrimerKlarnaError?) { - self.klarnaPaymentSessionCompletion?(authorizationToken, error) - self.klarnaPaymentSessionCompletion = nil + func primerKlarnaPaymentSessionFailed(error: Error) { + klarnaPaymentSessionCompletion?(nil, error) } } #endif diff --git a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/PaymentMethodTokenizationViewModel+Logic.swift b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/PaymentMethodTokenizationViewModel+Logic.swift index 7ccf5697d3..2e7b3b2050 100644 --- a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/PaymentMethodTokenizationViewModel+Logic.swift +++ b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/PaymentMethodTokenizationViewModel+Logic.swift @@ -53,8 +53,7 @@ extension PaymentMethodTokenizationViewModel { if let error = err as? PrimerError { primerErr = error } else { - primerErr = PrimerError.generic(message: err.localizedDescription, userInfo: nil, - diagnosticsId: UUID().uuidString) + primerErr = PrimerError.generic(message: err.localizedDescription, userInfo: nil, diagnosticsId: UUID().uuidString) } return PrimerDelegateProxy.raisePrimerDidFailWithError(primerErr, data: self.paymentCheckoutData) @@ -70,78 +69,87 @@ extension PaymentMethodTokenizationViewModel { func processPaymentMethodTokenData() { if PrimerInternal.shared.intent == .vault { - PrimerDelegateProxy.primerDidTokenizePaymentMethod(self.paymentMethodTokenData!) { _ in } - self.handleSuccessfulFlow() - + if config.internalPaymentMethodType != .klarna { + processVaultPaymentMethodTokenData() + return + } + processCheckoutPaymentMethodTokenData() } else { - self.didStartPayment?() - self.didStartPayment = nil + processCheckoutPaymentMethodTokenData() + } + } - let imageView = self.uiModule.makeIconImageView(withDimension: 24.0) - PrimerUIManager.primerRootViewController?.showLoadingScreenIfNeeded(imageView: imageView, - message: nil) + func processVaultPaymentMethodTokenData() { + PrimerDelegateProxy.primerDidTokenizePaymentMethod(self.paymentMethodTokenData!) { _ in } + self.handleSuccessfulFlow() + } - firstly { - self.startPaymentFlow(withPaymentMethodTokenData: self.paymentMethodTokenData!) - } - .done { checkoutData in - self.didFinishPayment?(nil) - self.nullifyEventCallbacks() + func processCheckoutPaymentMethodTokenData() { + self.didStartPayment?() + self.didStartPayment = nil - if PrimerSettings.current.paymentHandling == .auto, let checkoutData = checkoutData { - PrimerDelegateProxy.primerDidCompleteCheckoutWithData(checkoutData) - } + if config.internalPaymentMethodType != .klarna { + PrimerUIManager.primerRootViewController?.showLoadingScreenIfNeeded(imageView: self.uiModule.makeIconImageView(withDimension: 24.0), message: nil) + } - self.handleSuccessfulFlow() - } - .ensure { - PrimerUIManager.primerRootViewController?.enableUserInteraction(true) + firstly { + self.startPaymentFlow(withPaymentMethodTokenData: self.paymentMethodTokenData!) + } + .done { checkoutData in + self.didFinishPayment?(nil) + self.nullifyEventCallbacks() + + if PrimerSettings.current.paymentHandling == .auto, let checkoutData = checkoutData { + PrimerDelegateProxy.primerDidCompleteCheckoutWithData(checkoutData) } - .catch { err in - self.didFinishPayment?(err) - self.nullifyEventCallbacks() - - let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() - - if let primerErr = err as? PrimerError, - case .cancelled = primerErr, - PrimerInternal.shared.sdkIntegrationType == .dropIn, - PrimerInternal.shared.selectedPaymentMethodType == nil, - self.config.implementationType == .webRedirect || - self.config.type == PrimerPaymentMethodType.applePay.rawValue || - self.config.type == PrimerPaymentMethodType.adyenIDeal.rawValue || - self.config.type == PrimerPaymentMethodType.payPal.rawValue { - firstly { - clientSessionActionsModule.unselectPaymentMethodIfNeeded() - } - .done { _ in - PrimerUIManager.primerRootViewController?.popToMainScreen(completion: nil) - } - // The above promises will never end up on error. - .catch { _ in } - } else { - firstly { - clientSessionActionsModule.unselectPaymentMethodIfNeeded() - } - .then { () -> Promise in - var primerErr: PrimerError! - if let error = err as? PrimerError { - primerErr = error - } else { - primerErr = PrimerError.generic(message: err.localizedDescription, userInfo: nil, - diagnosticsId: UUID().uuidString) - } + self.handleSuccessfulFlow() + } + .ensure { + PrimerUIManager.primerRootViewController?.enableUserInteraction(true) + } + .catch { err in + self.didFinishPayment?(err) + self.nullifyEventCallbacks() - return PrimerDelegateProxy.raisePrimerDidFailWithError(primerErr, - data: self.paymentCheckoutData) - } - .done { merchantErrorMessage in - self.handleFailureFlow(errorMessage: merchantErrorMessage) + let clientSessionActionsModule: ClientSessionActionsProtocol = ClientSessionActionsModule() + + if let primerErr = err as? PrimerError, + case .cancelled = primerErr, + PrimerInternal.shared.sdkIntegrationType == .dropIn, + PrimerInternal.shared.selectedPaymentMethodType == nil, + self.config.implementationType == .webRedirect || + self.config.type == PrimerPaymentMethodType.applePay.rawValue || + self.config.type == PrimerPaymentMethodType.adyenIDeal.rawValue || + self.config.type == PrimerPaymentMethodType.payPal.rawValue { + firstly { + clientSessionActionsModule.unselectPaymentMethodIfNeeded() + } + .done { _ in + PrimerUIManager.primerRootViewController?.popToMainScreen(completion: nil) + } + // The above promises will never end up on error. + .catch { _ in } + + } else { + firstly { + clientSessionActionsModule.unselectPaymentMethodIfNeeded() + } + .then { () -> Promise in + var primerErr: PrimerError! + if let error = err as? PrimerError { + primerErr = error + } else { + primerErr = PrimerError.generic(message: err.localizedDescription, userInfo: nil, diagnosticsId: UUID().uuidString) } - // The above promises will never end up on error. - .catch { _ in } + + return PrimerDelegateProxy.raisePrimerDidFailWithError(primerErr, data: self.paymentCheckoutData) } + .done { merchantErrorMessage in + self.handleFailureFlow(errorMessage: merchantErrorMessage) + } + // The above promises will never end up on error. + .catch { _ in } } } } @@ -151,8 +159,7 @@ extension PaymentMethodTokenizationViewModel { var cancelledError: PrimerError? self.didCancel = { self.isCancelled = true - cancelledError = PrimerError.cancelled(paymentMethodType: self.config.type, userInfo: nil, - diagnosticsId: UUID().uuidString) + cancelledError = PrimerError.cancelled(paymentMethodType: self.config.type, userInfo: nil, diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: cancelledError!) seal.reject(cancelledError!) self.isCancelled = false @@ -258,8 +265,7 @@ extension PaymentMethodTokenizationViewModel { let err = PrimerError.invalidClientToken(userInfo: ["file": #file, "class": "\(Self.self)", "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + "line": "\(#line)"], diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) throw err } @@ -273,12 +279,10 @@ extension PaymentMethodTokenizationViewModel { case .fail(let message): var merchantErr: Error! if let message = message { - let err = PrimerError.merchantError(message: message, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + let err = PrimerError.merchantError(message: message, userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], diagnosticsId: UUID().uuidString) merchantErr = err } else { merchantErr = NSError.emptyDescriptionError @@ -299,8 +303,7 @@ extension PaymentMethodTokenizationViewModel { let err = PrimerError.invalidClientToken(userInfo: ["file": #file, "class": "\(Self.self)", "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + "line": "\(#line)"], diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) throw err } @@ -338,13 +341,10 @@ extension PaymentMethodTokenizationViewModel { } .done { paymentResponse -> Void in guard paymentResponse != nil else { - let err = PrimerError.invalidValue(key: "paymentResponse", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + let err = PrimerError.invalidValue(key: "paymentResponse", value: nil, userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], diagnosticsId: UUID().uuidString) throw err } @@ -362,8 +362,7 @@ extension PaymentMethodTokenizationViewModel { let err = PrimerError.invalidClientToken(userInfo: ["file": #file, "class": "\(Self.self)", "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + "line": "\(#line)"], diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) throw err } @@ -394,12 +393,10 @@ extension PaymentMethodTokenizationViewModel { case .fail(let message): var merchantErr: Error! if let message = message { - let err = PrimerError.merchantError(message: message, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + let err = PrimerError.merchantError(message: message, userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], diagnosticsId: UUID().uuidString) merchantErr = err } else { merchantErr = NSError.emptyDescriptionError @@ -428,13 +425,10 @@ extension PaymentMethodTokenizationViewModel { } else { guard let resumePaymentId = self.resumePaymentId else { - let resumePaymentIdError = PrimerError.invalidValue(key: "resumePaymentId", - value: "Resume Payment ID not valid", - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + let resumePaymentIdError = PrimerError.invalidValue(key: "resumePaymentId", value: "Resume Payment ID not valid", userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: resumePaymentIdError) seal.reject(resumePaymentIdError) return @@ -445,13 +439,10 @@ extension PaymentMethodTokenizationViewModel { } .done { paymentResponse -> Void in guard let paymentResponse = paymentResponse else { - let err = PrimerError.invalidValue(key: "paymentResponse", - value: nil, - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], - diagnosticsId: UUID().uuidString) + let err = PrimerError.invalidValue(key: "paymentResponse", value: nil, userInfo: ["file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)"], diagnosticsId: UUID().uuidString) ErrorHandler.handle(error: err) throw err } @@ -517,8 +508,7 @@ Make sure you call the decision handler otherwise the SDK will hang. private func handleCreatePaymentEvent(_ paymentMethodData: String) -> Promise { return Promise { seal in let createResumePaymentService: CreateResumePaymentServiceProtocol = CreateResumePaymentService() - let paymentRequest = Request.Body.Payment.Create(token: paymentMethodData) - createResumePaymentService.createPayment(paymentRequest: paymentRequest) { paymentResponse, error in + createResumePaymentService.createPayment(paymentRequest: Request.Body.Payment.Create(token: paymentMethodData)) { paymentResponse, error in if let error = error { if let paymentResponse { @@ -584,9 +574,7 @@ Make sure you call the decision handler otherwise the SDK will hang. private func handleResumePaymentEvent(_ resumePaymentId: String, resumeToken: String) -> Promise { return Promise { seal in let createResumePaymentService: CreateResumePaymentServiceProtocol = CreateResumePaymentService() - let resumeRequest = Request.Body.Payment.Resume(token: resumeToken) - createResumePaymentService.resumePaymentWithPaymentId(resumePaymentId, - paymentResumeRequest: resumeRequest) { paymentResponse, error in + createResumePaymentService.resumePaymentWithPaymentId(resumePaymentId, paymentResumeRequest: Request.Body.Payment.Resume(token: resumeToken)) { paymentResponse, error in if let error = error { if let paymentResponse { diff --git a/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/Contents.json b/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/Contents.json new file mode 100644 index 0000000000..a2cce1e2f7 --- /dev/null +++ b/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "arrow-left.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "arrow-left-1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "arrow-left-2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-1.png b/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-1.png new file mode 100644 index 0000000000000000000000000000000000000000..22e0d51cf492f2d01388c6a1d7c681528325cc7b GIT binary patch literal 955 zcmeAS@N?(olHy`uVBq!ia0vp^#vshW1|+Q(8fXD2mUKs7M+SzC{oH>NS%G|oWRD45bDP46hP^x@Isih!@P+6=(yLXi1ImO!M_+0GY$Vz{)7Z zzzk$D0wDvV6a&aKAdA6^Q5wz;V$^`DVPart&tzbMs)+*9AaDbSL3$uG^8!YMi3^zE zs*M*gBiJBqO(q)qffQ$fM`SSrgQ5ipGrCSQOaPh|nHdsM65;D(m7Jfemza{Dl&V*e zTL99lF>KRGth^d4b*A_61DO#%1i~?3^L!&&<3m$MGR_>jXub7B*#E}3Kj)=)sD+X zA0BLWT#|7Q*@1y<*`L-r%^KQ?ij)L->Hkhm+!S z934YN&8PnI(4DJl-YaXlZ?{|5m7`5=Y$q5b6_`^JY}l?np87BR+{dz({DA&V65E{~?bUT}y&#(UYQ~p~S*x$T(XV{5 zA+1X_GH0@0!n%!0!cSM4|I&zy3qT$;O{KRN1;ba}}@fhP;nncEGsA?@a7_ zTTPZHGiSW^XnnS-=Y2Og9$PcV0>n4p_IwSXhifeMgUpkpsljX_7w}`mbEO{k6MG6O<%9UHx3vIVCg!0NZ6H A*8l(j literal 0 HcmV?d00001 diff --git a/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-2.png b/Sources/PrimerSDK/Resources/Icons.xcassets/Common/arrow-left.imageset/arrow-left-2.png new file mode 100644 index 0000000000000000000000000000000000000000..15ba3c6ec998bc6bb9bcf4cd6199aae95fcd2b5b GIT binary patch literal 964 zcmeAS@N?(olHy`uVBq!ia0vp^E+EXo1|%(nCvO5$Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHa7y03zTt^8!YMi3^zE zs$CW^BiKM43{&rZ$OKZH1s;*b3=BHnAk4VbcG^myQIVM;5hW46K32*3xq68y`AMmI z6}bft z&@)i7<5EyiuqjGOvkG!?gK7uzY?U%fN(!v>^~=l4^~#O)@{7{-4J|D#^$m>ljf`}G zDs+o0^GXscbn}XpA%?)raY-#sF3Kz@$;{7F0GXSZlwVq6tE2?72o50bEXhnm*pycc z^%l^B`XCv7Lp=k1xYe9#Y4kM&FA8=jWSJv0 z_Y!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVl?05M1pgl1mAh%j*h z6I`{_0%imoq-{d6B+z{foCO|{#S9Drb3m9;?W~mv(6q?Rkcg59UmvUF{9L`nl>DSr zy^7odkS+$B3M(KpH?<^Dp&~aYuh^=>Rtc=a3djZt>nkaMm6T-LDnNc-DA*LGq*(>IxIwi8dA3R!B_#z``ugSN<$C4Ddih1^`i7R4mih)p`bI{& zKoz>hm3bwJ6}oxF$`C_f=D4I5Cl_TFlw{`TDS*sPOv*1Uu~kw6Sp)|Vca~(PA#BPk zhI$L=L4A;nzM-ChKHO}eRvVD0m48uYD$r(-`F4gjV3jChP6*KFC&J)ul0`9UcXk0vPg2<{6emysxYt1@0`Hv__vDh(ubT{a(NMZ`m zaeAQbizd1_L1uNj4-Tq*1OQ#z>6`P%$XPus}GnakFsG5sLwP6{<@iQUM=A3JL&#y&qT!vW3UW?5*NNf%A<^yG*|%x|i1Ye81Ygw`u$0Xs-8&%MPZfFj)1_f3bA-S=pY< zuDooI%-;EjxIqWEvoCvQb+fvfB_Y;@$}&*JThMakvl{Ad)vAm3hB*mQ>mT|!dvtYs z$v4Q7NrVJd3RwWn7x`*w_Di^Ys7+`g>ftCB?DJCITb(HY8H+-o!@wB_)f`%9+mHn`S2{?So_24y}z+&v~Q6p8twCKF~^MNGv%(cW^bX6O4dgB$r46ku#)_K)@&W$yLA<(;VE2$RZ+@1mqPEi%9?uwwePM zfE^wR5Wv=`;Ty6IZooHuCiHNp^Z<6WNsj`yT%CdfIzSwHq{ow;=uKP(@+@1XO)^0L$#`A$%6X1&maJ(2LkGxe!gk)%sMe#=yex6hA+Q zp>WvQ2rZ_QkcDALL|hJ+#|c0XP7p`H<*xIaW==58+_=BtYC@fXr8pFkH8D(7;Z5zb za5S010YVCAYJfmKKY$OC!32gN0;riBKsSkTf&yW7-ok{!pm#710`K12a)IyYL%8r3 zyd{SO@L@$J&l0#=n}Onv--1$`jY5~nIw&xV0A%3fmqn^e#sHaiM;#4>8YdhPB0LVC LLZ?TpUQYQR9T-{2 literal 0 HcmV?d00001 From 9ce93249437cfa879af3c7157b9d16704cbce234 Mon Sep 17 00:00:00 2001 From: Jack Newcombe Date: Thu, 28 Mar 2024 13:58:43 +0000 Subject: [PATCH 2/3] chore: Test Xcode 14.3.1 SPM build (#829) * Use xcode 14 in spm workflow * Update to 3ds sdk 14.3.1 fix branch to test fix * Update to 3ds sdk 14.3.1 fix branch to test fix #2 * Update 3DS SDK to 2.3.1 --- .github/workflows/build_test_upload.yml | 2 +- Debug App/Podfile.lock | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_test_upload.yml b/.github/workflows/build_test_upload.yml index b027442b75..a7ce05900e 100644 --- a/.github/workflows/build_test_upload.yml +++ b/.github/workflows/build_test_upload.yml @@ -79,7 +79,7 @@ jobs: - name: Select Xcode Version uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 with: - xcode-version: '15.1' + xcode-version: '14.3.1' - name: Install SSH key uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4 #v2.7.0 with: diff --git a/Debug App/Podfile.lock b/Debug App/Podfile.lock index d5905cc868..22b7199b05 100644 --- a/Debug App/Podfile.lock +++ b/Debug App/Podfile.lock @@ -1,6 +1,6 @@ PODS: - IQKeyboardManagerSwift (7.0.1) - - Primer3DS (2.3.0) + - Primer3DS (2.3.1) - PrimerIPay88MYSDK (0.1.7) - PrimerKlarnaSDK (1.1.0) - PrimerNolPaySDK (1.0.1) @@ -30,7 +30,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: IQKeyboardManagerSwift: 7f6b1b1d1497855d2beea7f2f10ffcc6978525b1 - Primer3DS: c70e939120c0e37aa968f89ecb59d5add5497720 + Primer3DS: 1caa7f7c764c9e94d5e122755ffc56343a771991 PrimerIPay88MYSDK: 436ee0be7e2c97e4e81456ccddee20175e9e3c4d PrimerKlarnaSDK: 83e9a1357a7247bf8fa2836fc945cf97644d601d PrimerNolPaySDK: 08b140ed39b378a0b33b4f8746544a402175c0cc 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..f068fa23f9 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 @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/primer-io/primer-sdk-3ds-ios", "state": { "branch": null, - "revision": "e1ec345543e1d7583dd574cecfb488b47b681936", - "version": "2.3.0" + "revision": "e400363648a3217501fffc283bc009e7919d782b", + "version": "2.3.1" } } ] From 730c082a98b9039a08baa84f6251ee3950fdfa54 Mon Sep 17 00:00:00 2001 From: Security Integrations Date: Thu, 28 Mar 2024 15:37:38 +0000 Subject: [PATCH 3/3] Release 2.25.0 (#831) [create-pull-request] automated change Co-authored-by: NQuinn27 <3179752+NQuinn27@users.noreply.github.com> --- .cz.toml | 2 +- CHANGELOG.md | 6 ++++++ PrimerSDK.podspec | 2 +- Sources/PrimerSDK/Classes/version.swift | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.cz.toml b/.cz.toml index 1df398d9ea..bc15052363 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] version_scheme = "semver" -version = "2.24.0" +version = "2.25.0" version_files = [ "Sources/PrimerSDK/Classes/version.swift:let PrimerSDKVersion", "PrimerSDK.podspec:s.version" diff --git a/CHANGELOG.md b/CHANGELOG.md index d579b0251a..3754f38355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.25.0 (2024-03-28) + +### Feat + +- Klarna Drop-IN Reskin (#822) + ## 2.24.0 (2024-03-18) ### Feat diff --git a/PrimerSDK.podspec b/PrimerSDK.podspec index 069c8170d5..9dbf3ee843 100644 --- a/PrimerSDK.podspec +++ b/PrimerSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "PrimerSDK" - s.version = "2.24.0" + s.version = "2.25.0" s.summary = "Official iOS SDK for Primer" s.description = <<-DESC This library contains the official iOS SDK for Primer. Install this Cocoapod to seemlessly integrate the Primer Checkout & API platform in your app. diff --git a/Sources/PrimerSDK/Classes/version.swift b/Sources/PrimerSDK/Classes/version.swift index 11cf925b95..a60e0214cd 100644 --- a/Sources/PrimerSDK/Classes/version.swift +++ b/Sources/PrimerSDK/Classes/version.swift @@ -1,2 +1,2 @@ // swiftlint:disable:next identifier_name -public let PrimerSDKVersion = "2.24.0" +public let PrimerSDKVersion = "2.25.0"