diff --git a/.github/actions/appetize-build/action.yml b/.github/actions/appetize-build/action.yml index ff43db8a33..b7b8be43ac 100644 --- a/.github/actions/appetize-build/action.yml +++ b/.github/actions/appetize-build/action.yml @@ -49,6 +49,9 @@ inputs: pr-number: description: PR number required: true + stripe-publishable-key: + description: 'Stripe publishable key' + required: true outputs: appetize-url: description: Appetize URL @@ -80,6 +83,10 @@ runs: - name: Install SMB shell: bash run: npm install --save slack-message-builder + - name: Create secrets.defaults.properties + shell: bash + run: | + echo "STRIPE_PUBLISHABLE_KEY=${{ inputs.stripe-publishable-key }}" > Debug\ App/secrets.defaults.properties - name: Test, Build, and Distribute app to Appetize 🚀 shell: bash run: | diff --git a/.github/workflows/build-test-upload.yml b/.github/workflows/build-test-upload.yml index 66789ac128..bc1247b370 100644 --- a/.github/workflows/build-test-upload.yml +++ b/.github/workflows/build-test-upload.yml @@ -251,6 +251,7 @@ jobs: slack-channel: ${{ secrets.SLACK_MOBILE_SDK_CHANNEL }} slack-reporter-token: ${{ secrets.SLACK_REPORTER_BOT_TOKEN }} github-run-id: ${{ github.run_id }} + stripe-publishable-key: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} - uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e #v3.1.0 if: ${{ success() }} id: find_comment diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 959bccf524..74958b3c0f 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -53,6 +53,7 @@ jobs: slack-channel: ${{ secrets.SLACK_MOBILE_SDK_CHANNEL }} slack-reporter-token: ${{ secrets.SLACK_REPORTER_BOT_TOKEN }} github-run-id: ${{ github.run_id }} + stripe-publishable-key: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} build-and-upload-to-firebase-and-browserstack: runs-on: macos-13 timeout-minutes: 20 @@ -96,6 +97,11 @@ jobs: env: FIREBASE_CREDENTIALS: ${{ secrets.FIREBASE_CREDENTIALS }} + - name: Create secrets.defaults.properties + shell: bash + run: | + echo "STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }}" > Debug\ App/secrets.defaults.properties + - name: Distribute internally on Firebase and upload to Browserstack 🚀 run: | bundle exec fastlane qa_release diff --git a/.gitignore b/.gitignore index fbb2dd48a2..57dcd29802 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,7 @@ playground.xcworkspace .build/ Package.swift.orig - +Package.resolved # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Debug App/Info.plist b/Debug App/Info.plist index 846f6bc115..4ef3fd64b0 100644 --- a/Debug App/Info.plist +++ b/Debug App/Info.plist @@ -69,8 +69,6 @@ UIInterfaceOrientationPortrait - UIUserInterfaceStyle - Light com.apple.developer.nfc.readersession.formats TAG diff --git a/Debug App/Primer.io Debug App SPM.xcodeproj/project.pbxproj b/Debug App/Primer.io Debug App SPM.xcodeproj/project.pbxproj index aad6e4e041..7d7823c369 100644 --- a/Debug App/Primer.io Debug App SPM.xcodeproj/project.pbxproj +++ b/Debug App/Primer.io Debug App SPM.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ E11F475D2B06C5A40091C31F /* BanksListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11F47592B06C5A40091C31F /* BanksListView.swift */; }; E11F475E2B06C5A40091C31F /* BanksListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11F475A2B06C5A40091C31F /* BanksListModel.swift */; }; E11F475F2B06C5A40091C31F /* ImageViewWithUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11F475B2B06C5A40091C31F /* ImageViewWithUrl.swift */; }; - E1AB44D02AFE139600639DC5 /* URL+NetworkHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AB44CF2AFE139600639DC5 /* URL+NetworkHeaders.swift */; }; F00C65F82ACC67FA00187028 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC5687FF32E8661F1A00CE5 /* AppDelegate.swift */; }; F00C65F92ACC67FA00187028 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1594BC5C96ECC3F46C811B2F /* Data+Extensions.swift */; }; F00C65FA2ACC67FA00187028 /* Range+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9128566126AA7F571FFECA3A /* Range+Extensions.swift */; }; @@ -62,6 +61,7 @@ F08F63E02BA0BE83006EF9A9 /* SessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63DF2BA0BE83006EF9A9 /* SessionConfiguration.swift */; }; F08F63E32BA0BEAD006EF9A9 /* AppetizeConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63E12BA0BEAD006EF9A9 /* AppetizeConfigProvider.swift */; }; F08F63E42BA0BEAD006EF9A9 /* MetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63E22BA0BEAD006EF9A9 /* MetadataParser.swift */; }; + F09978842D0326860058E4B5 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09978832D0326860058E4B5 /* Metadata.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -158,6 +158,7 @@ F08F63DF2BA0BE83006EF9A9 /* SessionConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionConfiguration.swift; sourceTree = ""; }; F08F63E12BA0BEAD006EF9A9 /* AppetizeConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppetizeConfigProvider.swift; sourceTree = ""; }; F08F63E22BA0BEAD006EF9A9 /* MetadataParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetadataParser.swift; sourceTree = ""; }; + F09978832D0326860058E4B5 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Metadata.swift; path = Sources/Model/Metadata.swift; sourceTree = ""; }; F816A2444633C4336A7CB071 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; F9023841AFCE8E3205CB713A /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; FB1F71737862EF5D0F4FE5AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -243,6 +244,7 @@ 1C9799A622D0CCDBBF94925B = { isa = PBXGroup; children = ( + F09978832D0326860058E4B5 /* Metadata.swift */, F04510DE2ACD4EA500A0A48C /* primer-sdk-ios */, FBDF3F8B5F93A0EC28048640 /* Project */, DF30711EB149C64C364BB79A /* Products */, @@ -493,6 +495,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F09978842D0326860058E4B5 /* Metadata.swift in Sources */, 8723ADE42B8E0FAB00A5FE23 /* MerchantHeadlessKlarnaInitializationView.swift in Sources */, F08F63E42BA0BEAD006EF9A9 /* MetadataParser.swift in Sources */, 044F805D2C6B4F9800E9F878 /* MerchantHeadlessStripeAchFieldsViewModel.swift in Sources */, @@ -515,7 +518,6 @@ 8723ADDD2B8E0F5100A5FE23 /* MerchantHelpers.swift in Sources */, F00C66022ACC67FA00187028 /* TestScenario.swift in Sources */, F00C66032ACC67FA00187028 /* TransactionResponse.swift in Sources */, - E1AB44D02AFE139600639DC5 /* URL+NetworkHeaders.swift in Sources */, E11F475D2B06C5A40091C31F /* BanksListView.swift in Sources */, F00C66052ACC67FA00187028 /* Networking.swift in Sources */, F00C66062ACC67FA00187028 /* MerchantDropInUIViewController.swift in Sources */, diff --git a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj index fd4db6159e..25bf41ca0d 100644 --- a/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj +++ b/Debug App/Primer.io Debug App.xcodeproj/project.pbxproj @@ -57,13 +57,13 @@ DE53DA2D0AD108306C92E198 /* UIViewController+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FD8065D40A2D691F643F3B /* UIViewController+API.swift */; }; E11F47542B06C5030091C31F /* BanksListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11F47502B06C5030091C31F /* BanksListView.swift */; }; E11F47562B06C5030091C31F /* ImageViewWithUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11F47522B06C5030091C31F /* ImageViewWithUrl.swift */; }; - E1A2971F2B021ABA005ADA51 /* URL+NetworkHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AB44D12AFE13AF00639DC5 /* URL+NetworkHeaders.swift */; }; EA7FAA4F8476BD3711D628CB /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B18D7E7738BF86467B0F1465 /* Images.xcassets */; }; F02F496FD20B5291C044F62C /* MerchantResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E3C8FE62D22147335F2455 /* MerchantResultViewController.swift */; }; F03699592AC2E63700E4179D /* BuildFile in Sources */ = {isa = PBXBuildFile; }; F08F63D82B9B5A7C006EF9A9 /* SessionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63D72B9B5A7C006EF9A9 /* SessionConfiguration.swift */; }; F08F63DA2B9B5BC5006EF9A9 /* AppetizeConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63D92B9B5BC5006EF9A9 /* AppetizeConfigProvider.swift */; }; F08F63DC2B9F27B0006EF9A9 /* MetadataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08F63DB2B9F27B0006EF9A9 /* MetadataParser.swift */; }; + F09978822D00B7620058E4B5 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09978812D00B7620058E4B5 /* Metadata.swift */; }; F0C2147F6FA26527BE55549A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = FC701AFD94F96F0F1D108D1A /* LaunchScreen.xib */; }; FD5ADBCFA70DB606339F3AF2 /* TransactionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D3D6CF0F006A06B7CEC71B /* TransactionResponse.swift */; }; /* End PBXBuildFile section */ @@ -200,7 +200,6 @@ E11F47512B06C5030091C31F /* BanksListModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BanksListModel.swift; sourceTree = ""; }; E11F47522B06C5030091C31F /* ImageViewWithUrl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewWithUrl.swift; sourceTree = ""; }; E129D66F2B162022004694F9 /* MerchantHeadlessCheckoutBankViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MerchantHeadlessCheckoutBankViewController.swift; sourceTree = ""; }; - E1AB44D12AFE13AF00639DC5 /* URL+NetworkHeaders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+NetworkHeaders.swift"; sourceTree = ""; }; E1C3EF0BA039C0A50EDE13A5 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; E3F4FB59DFC387F26CA944EB /* Pods_Debug_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Debug_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E7640DB186F9638C2F556F77 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; @@ -208,6 +207,7 @@ F08F63D72B9B5A7C006EF9A9 /* SessionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionConfiguration.swift; sourceTree = ""; }; F08F63D92B9B5BC5006EF9A9 /* AppetizeConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppetizeConfigProvider.swift; sourceTree = ""; }; F08F63DB2B9F27B0006EF9A9 /* MetadataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataParser.swift; sourceTree = ""; }; + F09978812D00B7620058E4B5 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; F816A2444633C4336A7CB071 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; F9023841AFCE8E3205CB713A /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; FB1F71737862EF5D0F4FE5AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -374,7 +374,6 @@ isa = PBXGroup; children = ( E8DE1E4FB055B60582977315 /* Networking.swift */, - E1AB44D12AFE13AF00639DC5 /* URL+NetworkHeaders.swift */, ); path = Network; sourceTree = ""; @@ -418,6 +417,7 @@ A6AEF11B151368BF993C3EA9 /* TestScenario.swift */, 70D3D6CF0F006A06B7CEC71B /* TransactionResponse.swift */, F08F63D72B9B5A7C006EF9A9 /* SessionConfiguration.swift */, + F09978812D00B7620058E4B5 /* Metadata.swift */, ); path = Model; sourceTree = ""; @@ -693,11 +693,11 @@ AAE3B30B64B6822A20987FCA /* CreateClientToken.swift in Sources */, F08F63D82B9B5A7C006EF9A9 /* SessionConfiguration.swift in Sources */, 87FC1ACB2BE5194100C9F474 /* MerchantHeadlessStripeAchFieldsViewModel.swift in Sources */, + F09978822D00B7620058E4B5 /* Metadata.swift in Sources */, 04F323652BD40A0E00F5927C /* MerchantHeadlessCheckoutBankViewController.swift in Sources */, 876141082B8346650058CA8C /* MerchantHeadlessKlarnaInitializationView.swift in Sources */, C75A11E6AEEFC2B7A29BBC04 /* TestScenario.swift in Sources */, F08F63DC2B9F27B0006EF9A9 /* MetadataParser.swift in Sources */, - E1A2971F2B021ABA005ADA51 /* URL+NetworkHeaders.swift in Sources */, FD5ADBCFA70DB606339F3AF2 /* TransactionResponse.swift in Sources */, 876140D52B63F7DB0058CA8C /* MerchantHeadlessCheckoutKlarnaViewController.swift in Sources */, 049A055E2B4BF057002CEEBA /* MerchantHeadlessVaultManagerViewController.swift in Sources */, diff --git a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard index a1d5cf8240..a59e62704e 100644 --- a/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard +++ b/Debug App/Resources/Localized Views/Base.lproj/Main.storyboard @@ -173,14 +173,14 @@ - + - - + + - + - - - - - - - - - - - - - + - - + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - + + - - + + + + + - - + + - - + + - - - - - - - - - - - + + - - - + + - - - + + - - - + + - - - - + + - - + + - - + + @@ -1386,11 +1382,11 @@ - - - - - + + + + + @@ -1415,7 +1411,7 @@ - + @@ -1430,7 +1426,7 @@ - + @@ -1466,9 +1462,7 @@ - - - + @@ -1983,13 +1977,13 @@ - + - + diff --git a/Debug App/Sources/Model/CreateClientToken.swift b/Debug App/Sources/Model/CreateClientToken.swift index d387591abc..15d9f304c9 100644 --- a/Debug App/Sources/Model/CreateClientToken.swift +++ b/Debug App/Sources/Model/CreateClientToken.swift @@ -55,162 +55,19 @@ enum Environment: String, Codable { } -struct CreateClientTokenRequest: Codable { - let orderId: String - let amount: Int? - let currencyCode: String - let customerId: String? - let metadata: [String: String]? - let customer: Customer? - let order: Order? - let paymentMethod: PaymentMethod? -} - -public struct Customer: Codable { - let firstName: String? - let lastName: String? - let emailAddress: String? - let billingAddress: Address? - let shippingAddress: Address? - let mobileNumber: String? - let nationalDocumentId: String? -} - -public struct LineItem: Codable { - let itemId: String? - let description: String? - let amount: Int? - let discountAmount: Int? - let quantity: Int? - let taxAmount: Int? - let taxCode: String? - - public init ( - itemId: String?, - description: String?, - amount: Int?, - discountAmount: Int?, - quantity: Int?, - taxAmount: Int?, - taxCode: String? - ) { - self.itemId = itemId - self.description = description - self.amount = amount - self.discountAmount = discountAmount - self.quantity = quantity - self.taxAmount = taxAmount - self.taxCode = taxCode - } -} - -public struct Order: Codable { - let countryCode: String? - // let fees: Fees? - let lineItems: [LineItem]? - let shipping: Shipping? - - public init ( - countryCode: String?, - // fees: Fees?, - lineItems: [LineItem]?, - shipping: Shipping? - ) { - self.countryCode = countryCode - // self.fees = fees - self.lineItems = lineItems - self.shipping = shipping - } -} - -public struct Fees: Codable { - let amount: UInt? - let description: String? - - public init ( - amount: UInt?, - description: String? - ) { - self.amount = amount - self.description = description - } -} - -public struct Shipping: Codable { - let amount: UInt - - public init(amount: UInt) { - self.amount = amount - } -} - -public struct PaymentMethod: Codable { - let vaultOnSuccess: Bool - - public init(vaultOnSuccess: Bool) { - self.vaultOnSuccess = vaultOnSuccess - } -} - -struct ClientSessionRequestBody { +struct ClientSessionRequestBody: Encodable { var clientToken: String? var customerId: String? var orderId: String? - var currencyCode: Currency? + var currencyCode: String? var amount: Int? - var metadata: [String: Any]? + var metadata: Metadata? var customer: ClientSessionRequestBody.Customer? var order: ClientSessionRequestBody.Order? var paymentMethod: ClientSessionRequestBody.PaymentMethod? var testParams: Test.Params? - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let clientToken = clientToken { - dic["clientToken"] = clientToken - } - - if let customerId = customerId { - dic["customerId"] = customerId - } - - if let orderId = orderId { - dic["orderId"] = orderId - } - - if let currencyCode = currencyCode { - dic["currencyCode"] = currencyCode.code - } - - if let amount = amount { - dic["amount"] = amount - } - - if let metadata = metadata { - dic["metadata"] = metadata - } - - if let customer = customer { - dic["customer"] = customer.dictionaryValue - } - - if let order = order { - dic["order"] = order.dictionaryValue - } - - if let paymentMethod = paymentMethod { - dic["paymentMethod"] = paymentMethod.dictionaryValue - } - - if let testParams = testParams { - dic["testParams"] = try? testParams.asDictionary() - } - - return dic.keys.count == 0 ? nil : dic - } - struct Customer: Codable { var firstName: String? var lastName: String? @@ -218,60 +75,12 @@ struct ClientSessionRequestBody { var mobileNumber: String? var billingAddress: Address? var shippingAddress: Address? - - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let firstName = firstName { - dic["firstName"] = firstName - } - - if let lastName = lastName { - dic["lastName"] = lastName - } - - if let emailAddress = emailAddress { - dic["emailAddress"] = emailAddress - } - - if let mobileNumber = mobileNumber { - dic["mobileNumber"] = mobileNumber - } - - if let mobileNumber = mobileNumber { - dic["mobileNumber"] = mobileNumber - } - - if let billingAddress = billingAddress { - dic["billingAddress"] = billingAddress.dictionaryValue - } - - if let shippingAddress = shippingAddress { - dic["shippingAddress"] = shippingAddress.dictionaryValue - } - - return dic.keys.count == 0 ? nil : dic - } } struct Order: Codable { var countryCode: CountryCode? var lineItems: [LineItem]? - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let countryCode = countryCode { - dic["countryCode"] = countryCode.rawValue - } - - if let lineItems = lineItems { - dic["lineItems"] = lineItems.compactMap({ $0.dictionaryValue }) - } - - return dic.keys.count == 0 ? nil : dic - } - struct LineItem: Codable { var itemId: String? @@ -281,93 +90,20 @@ struct ClientSessionRequestBody { var discountAmount: Int? var taxAmount: Int? var productType: String? - - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let itemId = itemId { - dic["itemId"] = itemId - } - - if let description = description { - dic["description"] = description - } - - if let amount = amount { - dic["amount"] = amount - } - - if let quantity = quantity { - dic["quantity"] = quantity - } - - if let taxAmount = taxAmount { - dic["taxAmount"] = taxAmount - } - - if let discountAmount = discountAmount { - dic["discountAmount"] = discountAmount - } - - if let productType = productType { - dic["productType"] = productType - } - - return dic.keys.count == 0 ? nil : dic - } } } struct PaymentMethod: Codable { - let vaultOnSuccess: Bool? + var vaultOnSuccess: Bool? + var vaultOnAgreement: Bool? var options: PaymentMethodOptionGroup? let descriptor: String? let paymentType: String? - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let vaultOnSuccess = vaultOnSuccess { - dic["vaultOnSuccess"] = vaultOnSuccess - } - - if let options = options { - dic["options"] = options.dictionaryValue - } - - if let descriptor = descriptor { - dic["descriptor"] = descriptor - } - - if let paymentType = paymentType { - dic["paymentType"] = paymentType - } - - return dic.keys.count == 0 ? nil : dic - } - struct PaymentMethodOptionGroup: Codable { var KLARNA: PaymentMethodOption? var PAYMENT_CARD: PaymentMethodOption? var APPLE_PAY: PaymentMethodOption? - - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let KLARNA = KLARNA { - dic["KLARNA"] = KLARNA.dictionaryValue - } - - if let PAYMENT_CARD = PAYMENT_CARD { - dic["PAYMENT_CARD"] = PAYMENT_CARD.dictionaryValue - } - - if let APPLE_PAY = APPLE_PAY { - dic["APPLE_PAY"] = APPLE_PAY.dictionaryValue - } - - return dic.keys.count == 0 ? nil : dic - } } struct PaymentMethodOption: Codable { @@ -431,61 +167,15 @@ struct ClientSessionRequestBody { captureVaultedCardCvv = try container.decodeIfPresent(Bool.self, forKey: .captureVaultedCardCvv) ?? false } - - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let surcharge = surcharge { - dic["surcharge"] = surcharge.dictionaryValue - } - - if let instalmentDuration = instalmentDuration { - dic["instalmentDuration"] = instalmentDuration - } - - if let extraMerchantData = extraMerchantData { - dic["extraMerchantData"] = extraMerchantData - } - - if let captureVaultedCardCvv = captureVaultedCardCvv, captureVaultedCardCvv == true { - dic["captureVaultedCardCvv"] = captureVaultedCardCvv - } - - if let merchantName = merchantName { - dic["merchantName"] = merchantName - } - - return dic.keys.count == 0 ? nil : dic - } } struct SurchargeOption: Codable { var amount: Int? - - var dictionaryValue: [String: Any]? { - var dic: [String: Any] = [:] - - if let amount = amount { - dic["amount"] = amount - } - - return dic.keys.count == 0 ? nil : dic - } } } } -extension Encodable { - func asDictionary() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { - throw NSError() - } - return dictionary - } -} - extension String { var jwtTokenPayload: JWTToken? { diff --git a/Debug App/Sources/Model/Metadata.swift b/Debug App/Sources/Model/Metadata.swift new file mode 100644 index 0000000000..c4bb84c2ae --- /dev/null +++ b/Debug App/Sources/Model/Metadata.swift @@ -0,0 +1,50 @@ +// +// Metadata.swift +// Debug App +// +// Created by Niall Quinn on 04/12/24. +// Copyright © 2024 Primer API Ltd. All rights reserved. +// + +import Foundation + +enum Metadata: Codable { + case string(String) + case bool(Bool) + case dictionary([String: Metadata]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .string(string) + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let dict = try? container.decode([String: Metadata].self) { + self = .dictionary(dict) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid metadata type") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .dictionary(let value): try container.encode(value) + } + } + + mutating func add(_ value: Metadata, forKey key: String) throws { + if case .dictionary(var dict) = self { + dict[key] = value + self = .dictionary(dict) + } else { + throw MetadataError.notDictionary + } + } + + enum MetadataError: Error { + case notDictionary + } +} diff --git a/Debug App/Sources/Model/SessionConfiguration.swift b/Debug App/Sources/Model/SessionConfiguration.swift index 368744e037..007d74667b 100644 --- a/Debug App/Sources/Model/SessionConfiguration.swift +++ b/Debug App/Sources/Model/SessionConfiguration.swift @@ -29,7 +29,6 @@ struct SessionConfiguration: Codable, Equatable { let postalCode: String let vault: Bool - let newWorkflows: Bool let environment: String let customApiKey: String diff --git a/Debug App/Sources/Network/Networking.swift b/Debug App/Sources/Network/Networking.swift index 886fd1a640..83e471262a 100644 --- a/Debug App/Sources/Network/Networking.swift +++ b/Debug App/Sources/Network/Networking.swift @@ -10,20 +10,19 @@ import Foundation import PrimerSDK enum APIVersion: String { - case v2 = "2021-09-27" - case v2_1 = "2.1" - case v2_2 = "2.2" - case v3 = "2021-10-19" - case v4 = "2021-12-01" - case v5 = "2021-12-10" + case v2 = "2021-09-27" + case v2_1 = "2.1" + case v2_2 = "2.2" + case v2_3 = "2.3" + case v2_4 = "2.4" } enum HTTPMethod: String { - case get = "GET" - case post = "POST" - case put = "PUT" + case get = "GET" + case post = "POST" + case put = "PUT" case delete = "DELETE" - case patch = "PATCH" + case patch = "PATCH" } enum NetworkError: Error { @@ -38,147 +37,161 @@ enum NetworkError: Error { private let logger = PrimerLogging.shared.logger class Networking { - + var endpoint: String { - get { - if environment == .local { - return "https://primer-mock-back-end.herokuapp.com" - } else { - return "https://us-central1-primerdemo-8741b.cloudfunctions.net" - } + if environment == .local { + return "https://primer-mock-back-end.herokuapp.com" + } else { + return "https://us-central1-primerdemo-8741b.cloudfunctions.net" } } - + func request( apiVersion: APIVersion?, url: URL, method: HTTPMethod, - headers: [String: String]?, + headers: [String: String]? = nil, queryParameters: [String: String]?, body: Data?, - completion: @escaping (_ result: Result) -> Void) { + completion: @escaping (_ result: Result) -> Void + ) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! - + if let queryParameters = queryParameters { components.queryItems = queryParameters.map { (key, value) in URLQueryItem(name: key, value: value) } } - components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") - + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences( + of: "+", with: "%2B") + logger.debug(message: "URL: \(components.url!.absoluteString )") - + var request = URLRequest(url: components.url!) request.httpMethod = method.rawValue - + request.addValue(environment.rawValue, forHTTPHeaderField: "environment") if method != .get { request.addValue("application/json", forHTTPHeaderField: "Content-Type") } - + if let customDefinedApiKey = customDefinedApiKey { request.addValue(customDefinedApiKey, forHTTPHeaderField: "x-api-key") } - + if let headers = headers { // We have a dedicated argument that takes x-api-key into account // in case a custom one gets defined before SDK initialization // so in case this array contains the same key, it won't be added - for header in headers.filter({ $0.value != "x-api-key"}) { + for header in headers.filter({ $0.key != "x-api-key" }) { request.addValue(header.value, forHTTPHeaderField: header.key) } } - + if let apiVersion = apiVersion { request.addValue(apiVersion.rawValue, forHTTPHeaderField: "x-api-version") request.addValue("IOS", forHTTPHeaderField: "Client") } - - let headerDescriptions = request.allHTTPHeaderFields?.map { key, value in + + let headerDescriptions = + request.allHTTPHeaderFields?.map { key, value in return "\(key) = \(value)" } ?? [] logger.debug(message: "Request Headers:\n\(headerDescriptions.joined(separator: "\n"))") - + if let body = body { request.httpBody = body if let bodyJson = try? JSONSerialization.jsonObject(with: body, options: .allowFragments) { logger.debug(message: "Request Body (json):\n\(bodyJson)") } } - - URLSession.shared.dataTask(with: request, completionHandler: { (data, response, err) in - DispatchQueue.main.async { - logger.debug(message: "Url: \(request.url?.absoluteString ?? "unknown")") - - if err != nil { - logger.debug(message: "Error: \(err!)") - completion(.failure(err!)) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - logger.debug(message: "Error: Invalid response") - completion(.failure(NetworkError.invalidResponse)) - return - } - - if httpResponse.statusCode < 200 || httpResponse.statusCode > 399 { - logger.debug(message: "Status Code: \(httpResponse.statusCode)") - if let data = data, let resJson = (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) as? [String: Any] { - logger.debug(message: "Response Body (json):\n\(resJson)") + + URLSession.shared.dataTask( + with: request, + completionHandler: { (data, response, err) in + DispatchQueue.main.async { + logger.debug(message: "Url: \(request.url?.absoluteString ?? "unknown")") + + if err != nil { + logger.debug(message: "Error: \(err!)") + completion(.failure(err!)) + return } - - guard let data = data else { - logger.error(message: "No data") + + guard let httpResponse = response as? HTTPURLResponse else { + logger.debug(message: "Error: Invalid response") completion(.failure(NetworkError.invalidResponse)) return } - - do { - let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) - logger.debug(message: "Response Body (json):\n\(json)") - } catch { - logger.error(message: "Failed to parse response body: \(error)") + + if httpResponse.statusCode < 200 || httpResponse.statusCode > 399 { + logger.debug(message: "Status Code: \(httpResponse.statusCode)") + if let data = data, + let resJson = + (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) + as? [String: Any] + { + logger.debug(message: "Response Body (json):\n\(resJson)") + } + + guard let data = data else { + logger.error(message: "No data") + completion(.failure(NetworkError.invalidResponse)) + return + } + + do { + let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + logger.debug(message: "Response Body (json):\n\(json)") + } catch { + logger.error(message: "Failed to parse response body: \(error)") + } + completion(.failure(NetworkError.invalidResponse)) + return } - completion(.failure(NetworkError.invalidResponse)) - return - } - - guard let data = data else { + + guard let data = data else { + logger.debug(message: "Status Code: \(httpResponse.statusCode)") + logger.debug(message: "Response Body: No data") + completion(.failure(NetworkError.invalidResponse)) + return + } + logger.debug(message: "Status Code: \(httpResponse.statusCode)") - logger.debug(message: "Response Body: No data") - completion(.failure(NetworkError.invalidResponse)) - return - } - - logger.debug(message: "Status Code: \(httpResponse.statusCode)") - if let resJson = (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) as? [String: Any] { - logger.debug(message: "Response Body (json):\n\(resJson)") - } else { - logger.debug(message: "Response Body (text):\n\(String(describing: String(data: data, encoding: .utf8)))") + if let resJson = (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) + as? [String: Any] + { + logger.debug(message: "Response Body (json):\n\(resJson)") + } else { + logger.debug( + message: + "Response Body (text):\n\(String(describing: String(data: data, encoding: .utf8)))") + } + + completion(.success(data)) } - - completion(.success(data)) } - }).resume() + ).resume() } - - static func resumePayment(_ paymentId: String, - withToken resumeToken: String, - completion: @escaping (Payment.Response?, Error?) -> Void) { + + static func resumePayment( + _ paymentId: String, + withToken resumeToken: String, + completion: @escaping (Payment.Response?, Error?) -> Void + ) { let url = environment.baseUrl.appendingPathComponent("/api/payments/\(paymentId)/resume") - + let body = Payment.ResumeRequest(token: resumeToken) - + var bodyData: Data! - + do { bodyData = try JSONEncoder().encode(body) } catch { completion(nil, NetworkError.missingParams) return } - + let networking = Networking() networking.request( apiVersion: nil, @@ -186,7 +199,8 @@ class Networking { method: .post, headers: nil, queryParameters: nil, - body: bodyData) { result in + body: bodyData + ) { result in switch result { case .success(let data): do { @@ -195,42 +209,44 @@ class Networking { } catch { completion(nil, error) } - + case .failure(let err): completion(nil, err) } } } - + static func createPayment( with paymentMethodTokenData: PrimerPaymentMethodTokenData, customDefinedApiKey: String? = nil, completion: @escaping (Payment.Response?, Error?) -> Void ) { let url = environment.baseUrl.appendingPathComponent("/api/payments/") - + guard let token = paymentMethodTokenData.token else { let err = PrimerError.invalidClientToken( - userInfo: ["file": #file, - "class": "\(Self.self)", - "function": #function, - "line": "\(#line)"], + userInfo: [ + "file": #file, + "class": "\(Self.self)", + "function": #function, + "line": "\(#line)", + ], diagnosticsId: UUID().uuidString) completion(nil, err) return } - + let body = Payment.CreateRequest(token: token) - + var bodyData: Data! - + do { bodyData = try JSONEncoder().encode(body) } catch { completion(nil, NetworkError.missingParams) return } - + let networking = Networking() networking.request( apiVersion: .v2, @@ -238,7 +254,8 @@ class Networking { method: .post, headers: nil, queryParameters: nil, - body: bodyData) { result in + body: bodyData + ) { result in switch result { case .success(let data): do { @@ -247,49 +264,52 @@ class Networking { } catch { completion(nil, error) } - + case .failure(let err): completion(nil, err) } } } - - static func requestClientSession(requestBody: ClientSessionRequestBody, customDefinedApiKey: String? = nil, completion: @escaping (String?, Error?) -> Void) { + + static func requestClientSession( + requestBody: ClientSessionRequestBody, customDefinedApiKey: String? = nil, + completion: @escaping (String?, Error?) -> Void + ) { let url = environment.baseUrl.appendingPathComponent("/api/client-session") - + var bodyData: Data! - + do { - if let requestBodyJson = requestBody.dictionaryValue { - bodyData = try JSONSerialization.data(withJSONObject: requestBodyJson, options: .fragmentsAllowed) - } else { - completion(nil, NetworkError.serializationError) - return - } + let encoder = JSONEncoder() + bodyData = try encoder.encode(requestBody) } catch { completion(nil, NetworkError.missingParams) return } - + let networking = Networking() networking.request( - apiVersion: .v2_2, + apiVersion: .v2_4, url: url, method: .post, - headers: URL.requestSessionHTTPHeaders(useNewWorkflows: useNewWorkflows), queryParameters: nil, body: bodyData ) { result in switch result { case .success(let data): do { - if let token = (try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any])?["clientToken"] as? String { + if let token = + (try JSONSerialization.jsonObject(with: data, options: .allowFragments) + as? [String: Any])?["clientToken"] as? String + { completion(token, nil) } else { - let err = NSError(domain: "example", code: 10, userInfo: [NSLocalizedDescriptionKey: "Failed to find client token"]) + let err = NSError( + domain: "example", code: 10, + userInfo: [NSLocalizedDescriptionKey: "Failed to find client token"]) completion(nil, err) } - + } catch { completion(nil, error) } @@ -298,27 +318,26 @@ class Networking { } } } - - static func patchClientSession(clientToken: String, requestBody: ClientSessionRequestBody, customDefinedApiKey: String? = nil, completion: @escaping (String?, Error?) -> Void) { + + static func patchClientSession( + clientToken: String, requestBody: ClientSessionRequestBody, customDefinedApiKey: String? = nil, + completion: @escaping (String?, Error?) -> Void + ) { let url = environment.baseUrl.appendingPathComponent("/api/client-session") - + var tmpRequestBody = requestBody tmpRequestBody.clientToken = clientToken - + let bodyData: Data! - + do { - if let requestBodyJson = tmpRequestBody.dictionaryValue { - bodyData = try JSONSerialization.data(withJSONObject: requestBodyJson, options: .fragmentsAllowed) - } else { - completion(nil, NetworkError.serializationError) - return - } + let encoder = JSONEncoder() + bodyData = try encoder.encode(requestBody) } catch { completion(nil, NetworkError.missingParams) return } - + let networking = Networking() networking.request( apiVersion: .v2_1, @@ -326,17 +345,23 @@ class Networking { method: .patch, headers: nil, queryParameters: nil, - body: bodyData) { result in + body: bodyData + ) { result in switch result { case .success(let data): do { - if let token = (try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any])?["clientToken"] as? String { + if let token = + (try JSONSerialization.jsonObject(with: data, options: .allowFragments) + as? [String: Any])?["clientToken"] as? String + { completion(token, nil) } else { - let err = NSError(domain: "example", code: 10, userInfo: [NSLocalizedDescriptionKey: "Failed to find client token"]) + let err = NSError( + domain: "example", code: 10, + userInfo: [NSLocalizedDescriptionKey: "Failed to find client token"]) completion(nil, err) } - + } catch { completion(nil, error) } @@ -347,8 +372,10 @@ class Networking { } } -internal extension String { - func toDate(withFormat f: String = "yyyy-MM-dd'T'HH:mm:ss.SSSZ", timeZone: TimeZone? = nil) -> Date? { +extension String { + func toDate(withFormat f: String = "yyyy-MM-dd'T'HH:mm:ss.SSSZ", timeZone: TimeZone? = nil) + -> Date? + { let df = DateFormatter() df.dateFormat = f df.locale = Locale(identifier: "en_US_POSIX") @@ -358,23 +385,23 @@ internal extension String { } public struct Payment { - + public struct CreateRequest: Encodable { let paymentMethodToken: String - + public init(token: String) { self.paymentMethodToken = token } } - + public struct ResumeRequest: Encodable { let resumeToken: String - + public init(token: String) { self.resumeToken = token } } - + public struct Response: Codable { public let id: String? public let paymentId: String? @@ -391,18 +418,19 @@ public struct Payment { public let requiredAction: Payment.Response.RequiredAction? public let status: Status public let paymentFailureReason: PrimerPaymentErrorCode.RawValue? - + public enum CodingKeys: String, CodingKey { - case id, paymentId, amount, currencyCode, customer, customerId, order, orderId, requiredAction, status, paymentFailureReason + case id, paymentId, amount, currencyCode, customer, customerId, order, orderId, + requiredAction, status, paymentFailureReason case dateStr = "date" } - + public struct RequiredAction: Codable { public let clientToken: String public let name: RequiredActionName public let description: String? } - + /// This enum is giong to be simplified removing the following cases: /// - authorized /// - settled diff --git a/Debug App/Sources/Network/URL+NetworkHeaders.swift b/Debug App/Sources/Network/URL+NetworkHeaders.swift deleted file mode 100644 index ddd86def5f..0000000000 --- a/Debug App/Sources/Network/URL+NetworkHeaders.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// URL+NetworkHeaders.swift -// Debug App -// -// Created by Alexandra Lovin on 09.11.2023. -// Copyright © 2023 Primer API Ltd. All rights reserved. -// - -import Foundation -extension URL { - static func requestSessionHTTPHeaders(useNewWorkflows: Bool) -> [String: String]? { - useNewWorkflows ? ["Legacy-Workflows": "false"] : nil - } -} diff --git a/Debug App/Sources/Utilities/MetadataParser.swift b/Debug App/Sources/Utilities/MetadataParser.swift index 88a904db10..d9b9e54feb 100644 --- a/Debug App/Sources/Utilities/MetadataParser.swift +++ b/Debug App/Sources/Utilities/MetadataParser.swift @@ -10,11 +10,11 @@ import Foundation struct MetadataParser { - func parse(_ metadata: String?) -> [String: Any] { + func parse(_ metadata: String?) -> [String: String] { guard let metadata = metadata else { return [:] } if let jsonData = metadata.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: String] { return jsonObject } @@ -22,10 +22,9 @@ struct MetadataParser { .split(separator: "\n") .map { $0.split(separator: "=").map { String($0).trimmingCharacters(in: .whitespaces) } } .filter { $0.count > 1 } - .map { (keyValue: [String]) -> (String, Any) in + .map { (keyValue: [String]) -> (String, String) in let (key, value) = (keyValue[0], keyValue[1]) - let parsedValue = parseValue(value) - return (key, parsedValue) + return (key, value) } return Dictionary(uniqueKeysWithValues: keyValuePairs) diff --git a/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift b/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift index 985e99012b..70b6bc5235 100644 --- a/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift +++ b/Debug App/Sources/View Controllers/Merchant Helpers/MerchantHelpers.swift @@ -32,7 +32,7 @@ struct MerchantMockDataManager { return ClientSessionRequestBody( customerId: customerId, orderId: "ios-order-\(String.randomString(length: 8))", - currencyCode: CurrencyLoader().getCurrency("EUR"), + currencyCode: CurrencyLoader().getCurrency("EUR")?.code, amount: nil, metadata: nil, customer: ClientSessionRequestBody.Customer( diff --git a/Debug App/Sources/View Controllers/MerchantDropInUIViewController.swift b/Debug App/Sources/View Controllers/MerchantDropInUIViewController.swift index 7f36deeb1a..462b34b500 100644 --- a/Debug App/Sources/View Controllers/MerchantDropInUIViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantDropInUIViewController.swift @@ -277,7 +277,8 @@ extension MerchantDropInUIViewController { print("\n\nMERCHANT APP\n\(#function)\nError: \(error)") self.primerError = error self.logs.append(#function) - + self.checkoutData = data + let message = "Merchant App | ERROR: \(error.localizedDescription)" decisionHandler(.fail(withErrorMessage: message)) } diff --git a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift index b50ea0823e..fe647d7370 100644 --- a/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.swift @@ -312,6 +312,7 @@ extension MerchantHeadlessCheckoutAvailablePaymentMethodsViewController { print("\n\nMERCHANT APP\n\(#function)\nerror: \(err)\ncheckoutData: \(String(describing: checkoutData))") self.logs.append(#function) self.primerError = err + self.checkoutData = checkoutData self.hideLoadingOverlay() if let lastViewController = navigationController?.children.last { diff --git a/Debug App/Sources/View Controllers/MerchantHeadlessVaultManagerViewController.swift b/Debug App/Sources/View Controllers/MerchantHeadlessVaultManagerViewController.swift index 7271bd2772..e8b878202e 100644 --- a/Debug App/Sources/View Controllers/MerchantHeadlessVaultManagerViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantHeadlessVaultManagerViewController.swift @@ -22,7 +22,6 @@ class MerchantHeadlessVaultManagerViewController: UIViewController, PrimerHeadle var settings: PrimerSettings! var clientSession: ClientSessionRequestBody? var clientToken: String? - var mandateDelegate: ACHMandateDelegate? var logs: [String] = [] var primerError: Error? @@ -75,7 +74,6 @@ class MerchantHeadlessVaultManagerViewController: UIViewController, PrimerHeadle private func startPrimerHeadlessUniversalCheckout(with clientToken: String) { PrimerHeadlessUniversalCheckout.current.start(withClientToken: clientToken, settings: self.settings, completion: { (_, _) in self.vaultedManager = PrimerHeadlessUniversalCheckout.VaultManager() - self.mandateDelegate = self.vaultedManager do { try self.vaultedManager?.configure() @@ -135,15 +133,7 @@ class MerchantHeadlessVaultManagerViewController: UIViewController, PrimerHeadle self.activityIndicator = nil } } - - private func showMandate() { - showAlert(title: "Mandate acceptance", message: "Would you like to accept this mandate?") { - self.mandateDelegate?.acceptMandate() - } cancelHandler: { - self.mandateDelegate?.declineMandate() - } - } - + private func showAlert(title: String, message: String, okHandler: (() -> Void)? = nil, cancelHandler: (() -> Void)? = nil) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let okAction = UIAlertAction(title: "OK", style: .default) { _ in okHandler?() } @@ -174,9 +164,12 @@ extension MerchantHeadlessVaultManagerViewController: UITableViewDataSource, UIT func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { showLoadingOverlay() let vaultedPaymentMethod = self.availablePaymentMethods[indexPath.row] - let vaultedCardAdditionalData = PrimerVaultedCardAdditionalData(cvv: "737") + if vaultedPaymentMethod.paymentMethodType == "PAYMENT_CARD" { + self.vaultedManager?.startPaymentFlow(vaultedPaymentMethodId: vaultedPaymentMethod.id, vaultedPaymentMethodAdditionalData: PrimerVaultedCardAdditionalData(cvv: "737")) + return + } - self.vaultedManager?.startPaymentFlow(vaultedPaymentMethodId: vaultedPaymentMethod.id, vaultedPaymentMethodAdditionalData: vaultedCardAdditionalData) + self.vaultedManager?.startPaymentFlow(vaultedPaymentMethodId: vaultedPaymentMethod.id) } } @@ -280,10 +273,6 @@ extension MerchantHeadlessVaultManagerViewController { DispatchQueue.main.async { self.hideLoadingOverlay() } - - if additionalInfo is ACHMandateAdditionalInfo { - showMandate() - } } func primerHeadlessUniversalCheckoutDidEnterResumePendingWithPaymentAdditionalInfo(_ additionalInfo: PrimerCheckoutAdditionalInfo?) { diff --git a/Debug App/Sources/View Controllers/MerchantResultViewController.swift b/Debug App/Sources/View Controllers/MerchantResultViewController.swift index ebaf106d32..e8091d1d07 100644 --- a/Debug App/Sources/View Controllers/MerchantResultViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantResultViewController.swift @@ -47,14 +47,18 @@ final class MerchantResultViewController: UIViewController { } if error != nil || checkoutData != nil { - let paymentEncodable = PrimerPaymentResultEncodable(id: checkoutData?.payment?.id, - orderId: checkoutData?.payment?.orderId) + let paymentEncodable = PrimerPaymentResultEncodable(id: checkoutData?.payment?.id, + orderId: checkoutData?.payment?.orderId) let encodable = PrimerResultEncodable(payment: paymentEncodable, error: error) guard let data = try? JSONEncoder().encode(encodable), let string = String(data: data, encoding: .utf8) else { responseTextView.text = "[\"Couldn't encode result to JSON\"]" return } responseTextView.text = string + + if let error = self.error { + responseTextView.text.append("\n\(error.localizedDescription)") + } } else if let error = self.error { responseTextView.text = "[\"\(error.localizedDescription)\"]" } else { @@ -63,8 +67,8 @@ final class MerchantResultViewController: UIViewController { responseStatus.font = .systemFont(ofSize: 17, weight: .medium) - responseStatus.textColor = error == nil ? .green : .red - responseStatus.text = error == nil ? "Success" : "Failure" + responseStatus.textColor = self.error == nil ? .green : .red + responseStatus.text = self.error == nil ? "Success" : "Failure" if logs.count > 0 { if let data = try? JSONSerialization.data(withJSONObject: logs) { diff --git a/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift b/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift index 57be90528b..3cf2c1f42c 100644 --- a/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift +++ b/Debug App/Sources/View Controllers/MerchantSessionAndSettingsViewController.swift @@ -12,43 +12,41 @@ import UIKit var environment: Environment = .sandbox var customDefinedApiKey: String? var performPaymentAfterVaulting: Bool = false -var useNewWorkflows = true var paymentSessionType: MerchantMockDataManager.SessionType = .generic class MerchantSessionAndSettingsViewController: UIViewController { - + enum RenderMode: Int { case createClientSession = 0 case clientToken case testScenario } - + // MARK: Stack Views - + @IBOutlet weak var environmentStackView: UIStackView! @IBOutlet weak var testParamsGroupStackView: UIStackView! @IBOutlet weak var apiKeyStackView: UIStackView! - @IBOutlet weak var useNewWorkflowsStackView: UIStackView! @IBOutlet weak var klarnaEMDStackView: UIStackView! @IBOutlet weak var clientTokenStackView: UIStackView! @IBOutlet weak var sdkSettingsStackView: UIStackView! @IBOutlet weak var orderStackView: UIStackView! @IBOutlet weak var customerStackView: UIStackView! @IBOutlet weak var surchargeGroupStackView: UIStackView! - + // MARK: Testing Mode Inputs - + @IBOutlet weak var testingModeSegmentedControl: UISegmentedControl! - + // MARK: Environment Inputs - + @IBOutlet weak var environmentSegmentedControl: UISegmentedControl! @IBOutlet weak var apiKeyTextField: UITextField! @IBOutlet weak var clientTokenTextField: UITextField! @IBOutlet weak var metadataTextField: UITextField! - + // MARK: Test Inputs - + @IBOutlet weak var testScenarioTextField: UITextField! @IBOutlet weak var testResultSegmentedControl: UISegmentedControl! @IBOutlet weak var testParamsStackView: UIStackView! @@ -59,20 +57,19 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBOutlet weak var testErrorDescriptionTextField: UITextField! @IBOutlet weak var test3DSStackView: UIStackView! @IBOutlet weak var test3DSScenarioTextField: UITextField! - + // MARK: SDK Settings Inputs - @IBOutlet weak var useNewWorkflowsSwitch: UISwitch! @IBOutlet weak var checkoutFlowSegmentedControl: UISegmentedControl! + @IBOutlet weak var vaultingFlowSegmentedControl: UISegmentedControl! @IBOutlet weak var merchantNameTextField: UITextField! @IBOutlet weak var applyThemingSwitch: UISwitch! - @IBOutlet weak var vaultPaymentsSwitch: UISwitch! @IBOutlet weak var disableSuccessScreenSwitch: UISwitch! @IBOutlet weak var disableErrorScreenSwitch: UISwitch! @IBOutlet weak var gesturesDismissalSwitch: UISwitch! @IBOutlet weak var closeButtonDismissalSwitch: UISwitch! @IBOutlet weak var disableInitScreenSwitch: UISwitch! @IBOutlet weak var enableCVVRecaptureFlowSwitch: UISwitch! - + // MARK: Apple Pay Inputs @IBOutlet weak var applePayCaptureBillingAddressSwitch: UISwitch! @IBOutlet weak var applePayCheckProvidedNetworksSwitch: UISwitch! @@ -92,21 +89,21 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBOutlet weak var applePayShippingContactPostalAddressSwitch: UISwitch! // MARK: Order Inputs - + @IBOutlet weak var currencyTextField: UITextField! @IBOutlet weak var countryCodeTextField: UITextField! @IBOutlet weak var orderIdTextField: UITextField! @IBOutlet weak var lineItemsStackView: UIStackView! @IBOutlet weak var totalAmountLabel: UILabel! - + // MARK: Customer Inputs - + @IBOutlet weak var customerIdTextField: UITextField! @IBOutlet weak var customerFirstNameTextField: UITextField! @IBOutlet weak var customerLastNameTextField: UITextField! @IBOutlet weak var customerEmailTextField: UITextField! @IBOutlet weak var customerMobileNumberTextField: UITextField! - + @IBOutlet weak var billingAddressSwitch: UISwitch! @IBOutlet weak var billingAddressStackView: UIStackView! @IBOutlet weak var billingAddressFirstNameTextField: UITextField! @@ -117,7 +114,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBOutlet weak var billingAddressStateTextField: UITextField! @IBOutlet weak var billingAddressPostalCodeTextField: UITextField! @IBOutlet weak var billingAddressCountryTextField: UITextField! - + @IBOutlet weak var shippingAddressStackView: UIStackView! @IBOutlet weak var shippingAddressSwitch: UISwitch! @IBOutlet weak var shippinAddressFirstNameTextField: UITextField! @@ -128,16 +125,16 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBOutlet weak var shippinAddressStateTextField: UITextField! @IBOutlet weak var shippinAddressPostalCodeTextField: UITextField! @IBOutlet weak var shippinAddressCountryTextField: UITextField! - + // MARK: Surcharge Inputs - + @IBOutlet weak var surchargeSwitch: UISwitch! @IBOutlet weak var surchargeStackView: UIStackView! @IBOutlet weak var applePaySurchargeTextField: UITextField! - + @IBOutlet weak var primerSDKButton: UIButton! @IBOutlet weak var primerHeadlessSDKButton: UIButton! - + var lineItems: [ClientSessionRequestBody.Order.LineItem] { get { return self.clientSession.order?.lineItems ?? [] @@ -146,24 +143,24 @@ class MerchantSessionAndSettingsViewController: UIViewController { self.clientSession.order?.lineItems = newValue } } - + let testScenarioPicker = UIPickerView() let testFailureFlowPicker = UIPickerView() let test3DSScenarioPicker = UIPickerView() - + var renderMode: RenderMode = .createClientSession - + var selectedPaymentHandling: PrimerPaymentHandling = .auto - + var clientSession = MerchantMockDataManager.getClientSession(sessionType: .generic) - + var selectedTestScenario: Test.Scenario? var selectedTestFlow: Test.Flow? var selectedTest3DSScenario: Test.Params.ThreeDS.Scenario? - + var applyTheme: Bool = false var payAfterVaultSuccess: Bool = false - + var applePayCaptureBillingAddress = false var applePayBillingAdditionalContactFields: [PrimerApplePayOptions.RequiredContactField]? = [] var applePayCaptureShippingDetails = false @@ -176,25 +173,25 @@ class MerchantSessionAndSettingsViewController: UIViewController { self.testingModeSegmentedControl.accessibilityIdentifier = "Testing Mode Segmented Control" self.clientTokenTextField.accessibilityIdentifier = "Client Token Text Field" } - + // MARK: - VIEW LIFE-CYCLE - + override func viewDidLoad() { super.viewDidLoad() - + self.setAccessibilityIds() testScenarioPicker.dataSource = self testScenarioPicker.delegate = self testScenarioTextField.inputView = testScenarioPicker - + testFailureFlowPicker.dataSource = self testFailureFlowPicker.delegate = self testFailureFlowTextField.inputView = testFailureFlowPicker - + test3DSScenarioPicker.dataSource = self test3DSScenarioPicker.delegate = self test3DSScenarioTextField.inputView = test3DSScenarioPicker - + switch environment { case .dev: environmentSegmentedControl.selectedSegmentIndex = 0 @@ -207,40 +204,43 @@ class MerchantSessionAndSettingsViewController: UIViewController { default: environmentSegmentedControl.selectedSegmentIndex = 1 } - + self.apiKeyTextField.text = customDefinedApiKey - + let viewTap = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) view.addGestureRecognizer(viewTap) - + merchantNameTextField.text = "Primer Merchant" populateSessionSettingsFields() - - customerIdTextField.addTarget(self, action: #selector(customerIdChanged(_:)), for: .editingDidEnd) - + + customerIdTextField.addTarget( + self, action: #selector(customerIdChanged(_:)), for: .editingDidEnd) + handleAppetizeIfNeeded(AppetizeConfigProvider()) - + render() - - NotificationCenter.default.addObserver(self, selector: #selector(handleAppetizeConfig), name: NSNotification.Name.appetizeURLHandled, object: nil) + + NotificationCenter.default.addObserver( + self, selector: #selector(handleAppetizeConfig), name: NSNotification.Name.appetizeURLHandled, + object: nil) } - + @objc func handleAppetizeConfig(_ notification: NSNotification) { if let payloadProvider = notification.object as? DeeplinkConfigProvider { handleAppetizeIfNeeded(AppetizeConfigProvider(payloadProvider: payloadProvider)) } } - + private func handleAppetizeIfNeeded(_ configProvider: AppetizeConfigProvider) { if let config = configProvider.fetchConfig() { updateUI(for: config) } } - + @objc func viewTapped() { view.endEditing(true) } - + func render() { switch renderMode { case .createClientSession: @@ -252,9 +252,8 @@ class MerchantSessionAndSettingsViewController: UIViewController { orderStackView.isHidden = false customerStackView.isHidden = false surchargeGroupStackView.isHidden = false - useNewWorkflowsStackView.isHidden = false klarnaEMDStackView.isHidden = false - + case .clientToken: environmentStackView.isHidden = false testParamsGroupStackView.isHidden = true @@ -264,9 +263,8 @@ class MerchantSessionAndSettingsViewController: UIViewController { orderStackView.isHidden = true customerStackView.isHidden = true surchargeGroupStackView.isHidden = true - useNewWorkflowsStackView.isHidden = true klarnaEMDStackView.isHidden = true - + case .testScenario: environmentStackView.isHidden = true testParamsGroupStackView.isHidden = false @@ -276,17 +274,16 @@ class MerchantSessionAndSettingsViewController: UIViewController { orderStackView.isHidden = false customerStackView.isHidden = false surchargeGroupStackView.isHidden = false - useNewWorkflowsStackView.isHidden = true klarnaEMDStackView.isHidden = true - + testParamsStackView.isHidden = (selectedTestScenario == nil) - + if testResultSegmentedControl.selectedSegmentIndex == 0 { testFailureStackView.isHidden = true } else { testFailureStackView.isHidden = false } - + switch selectedTestScenario { case .testNative3DS: if testResultSegmentedControl.selectedSegmentIndex == 0 { @@ -298,50 +295,51 @@ class MerchantSessionAndSettingsViewController: UIViewController { test3DSStackView.isHidden = true } } - - gesturesDismissalSwitch.isOn = true // Default value - closeButtonDismissalSwitch.isOn = false // Default false - + + gesturesDismissalSwitch.isOn = true // Default value + closeButtonDismissalSwitch.isOn = false // Default false + lineItemsStackView.removeAllArrangedSubviews() lineItemsStackView.alignment = .fill lineItemsStackView.distribution = .fill - + for (index, lineItem) in lineItems.enumerated() { let horizontalStackView = UIStackView() horizontalStackView.tag = index horizontalStackView.axis = .horizontal horizontalStackView.alignment = .fill horizontalStackView.distribution = .fill - + let nameLbl = UILabel() nameLbl.text = (lineItem.description ?? "") + " x\(lineItem.quantity ?? 1)" nameLbl.textAlignment = .left nameLbl.font = UIFont.systemFont(ofSize: 14) horizontalStackView.addArrangedSubview(nameLbl) - + let priceLbl = UILabel() priceLbl.text = "\(lineItem.amount ?? 0)" priceLbl.textAlignment = .right priceLbl.font = UIFont.systemFont(ofSize: 14) horizontalStackView.addArrangedSubview(priceLbl) - - let lineItemTapGesture = UITapGestureRecognizer(target: self, action: #selector(lineItemTapped)) + + let lineItemTapGesture = UITapGestureRecognizer( + target: self, action: #selector(lineItemTapped)) horizontalStackView.addGestureRecognizer(lineItemTapGesture) - + lineItemsStackView.addArrangedSubview(horizontalStackView) } - + let totalAmount = lineItems.compactMap({ (($0.quantity ?? 0) * ($0.amount ?? 0)) }).reduce(0, +) totalAmountLabel.text = "\(totalAmount)" } - + // MARK: - ACTIONS - + @objc func lineItemTapped(_ sender: UITapGestureRecognizer) { guard let index = sender.view?.tag, lineItems.count > index else { return } - + let lineItem = lineItems[index] let vc = MerchantNewLineItemViewController.instantiate(lineItem: lineItem) vc.onLineItemEdited = { lineItem in @@ -354,12 +352,12 @@ class MerchantSessionAndSettingsViewController: UIViewController { } navigationController?.pushViewController(vc, animated: true) } - + @IBAction func testingModeSegmentedControlValueChanged(_ sender: UISegmentedControl) { renderMode = RenderMode(rawValue: sender.selectedSegmentIndex)! render() } - + @IBAction func environmentSegmentedControlValuewChanged(_ sender: UISegmentedControl) { switch sender.selectedSegmentIndex { case 0: @@ -374,11 +372,11 @@ class MerchantSessionAndSettingsViewController: UIViewController { fatalError() } } - + @IBAction func testResultSegmentedControlValueChanged(_ sender: UISegmentedControl) { render() } - + @IBAction func checkoutFlowSegmentedControlValueChanged(_ sender: UISegmentedControl) { switch sender.selectedSegmentIndex { case 0: @@ -389,7 +387,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { fatalError() } } - + @IBAction func addLineItemButtonTapped(_ sender: Any) { let vc = MerchantNewLineItemViewController.instantiate(lineItem: nil) vc.onLineItemAdded = { lineItem in @@ -398,28 +396,24 @@ class MerchantSessionAndSettingsViewController: UIViewController { } navigationController?.pushViewController(vc, animated: true) } - + @IBAction func billingAddressSwitchValueChanged(_ sender: UISwitch) { billingAddressStackView.isHidden = !sender.isOn } - + @IBAction func shippingAddressSwitchValueChanged(_ sender: UISwitch) { shippingAddressStackView.isHidden = !sender.isOn } - + @IBAction func surchargeSwitchValueChanged(_ sender: UISwitch) { surchargeStackView.isHidden = !sender.isOn } - @IBAction func useNewWorkflowsSwitchValueChanged(_ sender: UISwitch) { - useNewWorkflows = sender.isOn - } - @IBAction func oneTimePaymentValueChanged(_ sender: UISwitch) { paymentSessionType = sender.isOn ? .klarnaWithEMD : .generic populateSessionSettingsFields() } - + @IBAction func applePayCaptureBillingAddressSwitchValueChanged(_ sender: UISwitch) { applePayBillingControlStackView.isHidden = !sender.isOn applePayCaptureBillingAddress = sender.isOn @@ -494,7 +488,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { @IBAction func applePayRequireShippingMethodSwitchChanged(_ sender: UISwitch) { applePayRequireShippingMethod = sender.isOn } - + @IBAction func applePayShippingContactNameSwitchChanged(_ sender: UISwitch) { if sender.isOn { var fields = applePayShippingAdditionalContactFields ?? [] @@ -510,7 +504,6 @@ class MerchantSessionAndSettingsViewController: UIViewController { } } - @IBAction func applePayShippingContactEmailField(_ sender: UISwitch) { if sender.isOn { var fields = applePayShippingAdditionalContactFields ?? [] @@ -525,7 +518,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { } } } - + @IBAction func applePayShippingContactPhoneSwitchChanged(_ sender: UISwitch) { if sender.isOn { var fields = applePayShippingAdditionalContactFields ?? [] @@ -555,13 +548,13 @@ class MerchantSessionAndSettingsViewController: UIViewController { } } } - + @IBAction func applePayCheckProvidedNetworksSwitchValueChanged(_ sender: UISwitch) { applePayCheckProvidedNetworks = sender.isOn } func configureClientSession() { - clientSession.currencyCode = CurrencyLoader().getCurrency(currencyTextField.text ?? "") + clientSession.currencyCode = CurrencyLoader().getCurrency(currencyTextField.text ?? "")?.code clientSession.order?.countryCode = CountryCode(rawValue: countryCodeTextField.text ?? "") clientSession.orderId = orderIdTextField.text clientSession.customerId = customerIdTextField.text @@ -569,7 +562,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { clientSession.customer?.lastName = customerLastNameTextField.text clientSession.customer?.emailAddress = customerEmailTextField.text clientSession.customer?.mobileNumber = customerMobileNumberTextField.text - + if billingAddressSwitch.isOn { clientSession.customer?.billingAddress?.firstName = billingAddressFirstNameTextField.text clientSession.customer?.billingAddress?.lastName = billingAddressLastNameTextField.text @@ -582,7 +575,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { } else { clientSession.customer?.billingAddress = nil } - + if shippingAddressSwitch.isOn { clientSession.customer?.shippingAddress?.firstName = shippinAddressFirstNameTextField.text clientSession.customer?.shippingAddress?.lastName = shippinAddressLastNameTextField.text @@ -595,46 +588,76 @@ class MerchantSessionAndSettingsViewController: UIViewController { } else { clientSession.customer?.shippingAddress = nil } - - clientSession.paymentMethod = MerchantMockDataManager.getPaymentMethod(sessionType: paymentSessionType) + + clientSession.paymentMethod = MerchantMockDataManager.getPaymentMethod( + sessionType: paymentSessionType) if paymentSessionType == .generic && enableCVVRecaptureFlowSwitch.isOn { - let option = ClientSessionRequestBody.PaymentMethod.PaymentMethodOption(surcharge: nil, - instalmentDuration: nil, - extraMerchantData: nil, - captureVaultedCardCvv: enableCVVRecaptureFlowSwitch.isOn, - merchantName: nil) - + let option = ClientSessionRequestBody.PaymentMethod.PaymentMethodOption( + surcharge: nil, + instalmentDuration: nil, + extraMerchantData: nil, + captureVaultedCardCvv: enableCVVRecaptureFlowSwitch.isOn, + merchantName: nil) + clientSession.paymentMethod?.options?.PAYMENT_CARD = option } - let applePayOptions = ClientSessionRequestBody.PaymentMethod.PaymentMethodOption(surcharge: nil, - instalmentDuration: nil, - extraMerchantData: nil, - captureVaultedCardCvv: nil, - merchantName: "Primer Merchant iOS") - + let applePayOptions = ClientSessionRequestBody.PaymentMethod.PaymentMethodOption( + surcharge: nil, + instalmentDuration: nil, + extraMerchantData: nil, + captureVaultedCardCvv: nil, + merchantName: "Primer Merchant iOS") + clientSession.paymentMethod?.options?.APPLE_PAY = applePayOptions - if let metadata = metadataTextField.text, !metadata.isEmpty { - clientSession.metadata = MetadataParser().parse(metadata) + if vaultingFlowSegmentedControl.selectedSegmentIndex == 1 { + clientSession.paymentMethod?.vaultOnSuccess = true + clientSession.paymentMethod?.vaultOnAgreement = nil + } else if vaultingFlowSegmentedControl.selectedSegmentIndex == 2 { + clientSession.paymentMethod?.vaultOnAgreement = true + clientSession.paymentMethod?.vaultOnSuccess = nil + } else { + clientSession.paymentMethod?.vaultOnSuccess = nil + clientSession.paymentMethod?.vaultOnAgreement = nil + } + + clientSession.metadata = .dictionary([ + "deviceInfo": .dictionary([ + "ipAddress": .string("127.0.0.1"), + "userAgent": .string("iOS") + ]) + ]) + + if let metadata = metadataTextField.text, !metadata.isEmpty, var metadataDict = clientSession.metadata { + metadataTextField.text?.components(separatedBy: ",").forEach { + let tuple = String($0).components(separatedBy: "=") + guard tuple.count == 2 + else { return } + let key = tuple[0].trimmingCharacters(in: .whitespaces) + let value = tuple[1].trimmingCharacters(in: .whitespaces) + try? metadataDict.add(.string(value), forKey: key) + } + clientSession.metadata = metadataDict } } - + func populateSessionSettingsFields() { clientSession = MerchantMockDataManager.getClientSession(sessionType: paymentSessionType) - - enableCVVRecaptureFlowSwitch.isOn = clientSession.paymentMethod?.options?.PAYMENT_CARD?.captureVaultedCardCvv == true - - currencyTextField.text = clientSession.currencyCode?.code + + enableCVVRecaptureFlowSwitch.isOn = + clientSession.paymentMethod?.options?.PAYMENT_CARD?.captureVaultedCardCvv == true + + currencyTextField.text = clientSession.currencyCode countryCodeTextField.text = clientSession.order?.countryCode?.rawValue orderIdTextField.text = clientSession.orderId - + customerIdTextField.text = clientSession.customerId customerFirstNameTextField.text = clientSession.customer?.firstName customerLastNameTextField.text = clientSession.customer?.lastName customerEmailTextField.text = clientSession.customer?.emailAddress customerMobileNumberTextField.text = clientSession.customer?.mobileNumber - + billingAddressSwitch.isOn = true billingAddressFirstNameTextField.text = clientSession.customer?.billingAddress?.firstName billingAddressLastNameTextField.text = clientSession.customer?.billingAddress?.lastName @@ -644,7 +667,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { billingAddressStateTextField.text = clientSession.customer?.billingAddress?.state billingAddressPostalCodeTextField.text = clientSession.customer?.billingAddress?.postalCode billingAddressCountryTextField.text = clientSession.customer?.billingAddress?.countryCode - + shippingAddressSwitch.isOn = true shippinAddressFirstNameTextField.text = clientSession.customer?.shippingAddress?.firstName shippinAddressLastNameTextField.text = clientSession.customer?.shippingAddress?.lastName @@ -655,54 +678,58 @@ class MerchantSessionAndSettingsViewController: UIViewController { shippinAddressPostalCodeTextField.text = clientSession.customer?.shippingAddress?.postalCode shippinAddressCountryTextField.text = clientSession.customer?.shippingAddress?.countryCode } - + func configureTestScenario() { guard let selectedTestScenario = selectedTestScenario else { - let alert = UIAlertController(title: "Error", message: "Please choose Test Scenario", preferredStyle: .alert) + let alert = UIAlertController( + title: "Error", message: "Please choose Test Scenario", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) return } - + var testParams = Test.Params( scenario: selectedTestScenario, result: .success, network: nil, polling: nil, threeDS: nil) - + if testResultSegmentedControl.selectedSegmentIndex == 1 { guard let selectedTestFlow = selectedTestFlow else { - let alert = UIAlertController(title: "Error", message: "Please choose failure flow in the Failure Parameters", preferredStyle: .alert) + let alert = UIAlertController( + title: "Error", message: "Please choose failure flow in the Failure Parameters", + preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) return } - + let failure = Test.Params.Failure( flow: selectedTestFlow, error: Test.Params.Failure.Error( errorId: testErrorIdTextField.text ?? "test-error-id", description: testErrorDescriptionTextField.text ?? "test-error-description")) - + testParams.result = .failure(failure: failure) - + } else if case .testNative3DS = selectedTestScenario { guard let selectedTest3DSScenario = selectedTest3DSScenario else { - let alert = UIAlertController(title: "Error", message: "Please choose 3DS scenario", preferredStyle: .alert) + let alert = UIAlertController( + title: "Error", message: "Please choose 3DS scenario", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) return } testParams.threeDS = Test.Params.ThreeDS(scenario: selectedTest3DSScenario) } - + clientSession.testParams = testParams } - + @IBAction func primerSDKButtonTapped(_ sender: Any) { customDefinedApiKey = (apiKeyTextField.text ?? "").isEmpty ? nil : apiKeyTextField.text - + let selectedDismissalMechanisms: [DismissalMechanism] = { var mechanisms = [DismissalMechanism]() if gesturesDismissalSwitch.isOn { @@ -713,14 +740,14 @@ class MerchantSessionAndSettingsViewController: UIViewController { } return mechanisms }() - + let uiOptions = PrimerUIOptions( isInitScreenEnabled: !disableInitScreenSwitch.isOn, isSuccessScreenEnabled: !disableSuccessScreenSwitch.isOn, isErrorScreenEnabled: !disableErrorScreenSwitch.isOn, dismissalMechanism: selectedDismissalMechanisms, theme: applyThemingSwitch.isOn ? CheckoutTheme.tropical : nil) - + let mandateData = PrimerStripeOptions.MandateData.templateMandate(merchantName: "Primer Inc.") let shippingOptions = applePayCaptureShippingDetails ? @@ -731,7 +758,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { PrimerApplePayOptions.BillingOptions(requiredBillingContactFields: applePayBillingAdditionalContactFields) : nil let stripePublishableKey = SecretsManager.shared.value(forKey: .stripePublishableKey) - + let settings = PrimerSettings( paymentHandling: selectedPaymentHandling, paymentMethodOptions: PrimerPaymentMethodOptions( @@ -748,22 +775,24 @@ class MerchantSessionAndSettingsViewController: UIViewController { uiOptions: uiOptions, debugOptions: PrimerDebugOptions(is3DSSanityCheckEnabled: false) ) - + switch renderMode { case .createClientSession, .testScenario: configureClientSession() if case .testScenario = renderMode { configureTestScenario() } - let vc = MerchantDropInUIViewController.instantiate(settings: settings, clientSession: clientSession, clientToken: nil) + let vc = MerchantDropInUIViewController.instantiate( + settings: settings, clientSession: clientSession, clientToken: nil) navigationController?.pushViewController(vc, animated: true) - + case .clientToken: - let vc = MerchantDropInUIViewController.instantiate(settings: settings, clientSession: nil, clientToken: clientTokenTextField.text) + let vc = MerchantDropInUIViewController.instantiate( + settings: settings, clientSession: nil, clientToken: clientTokenTextField.text) navigationController?.pushViewController(vc, animated: true) } } - + @IBAction func primerHeadlessButtonTapped(_ sender: Any) { customDefinedApiKey = (apiKeyTextField.text ?? "").isEmpty ? nil : apiKeyTextField.text @@ -775,7 +804,7 @@ class MerchantSessionAndSettingsViewController: UIViewController { PrimerApplePayOptions.BillingOptions(requiredBillingContactFields: applePayBillingAdditionalContactFields) : nil let stripePublishableKey = SecretsManager.shared.value(forKey: .stripePublishableKey) - + let settings = PrimerSettings( paymentHandling: selectedPaymentHandling, paymentMethodOptions: PrimerPaymentMethodOptions( @@ -792,25 +821,27 @@ class MerchantSessionAndSettingsViewController: UIViewController { uiOptions: nil, debugOptions: PrimerDebugOptions(is3DSSanityCheckEnabled: false) ) - + switch renderMode { case .createClientSession, .testScenario: configureClientSession() if case .testScenario = renderMode { configureTestScenario() } - let vc = MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.instantiate(settings: settings, - clientSession: clientSession, - clientToken: nil) + let vc = MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.instantiate( + settings: settings, + clientSession: clientSession, + clientToken: nil) navigationController?.pushViewController(vc, animated: true) case .clientToken: - let vc = MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.instantiate(settings: settings, - clientSession: nil, - clientToken: clientTokenTextField.text) + let vc = MerchantHeadlessCheckoutAvailablePaymentMethodsViewController.instantiate( + settings: settings, + clientSession: nil, + clientToken: clientTokenTextField.text) navigationController?.pushViewController(vc, animated: true) } } - + @objc func customerIdChanged(_ textField: UITextField!) { guard let text = customerIdTextField.text else { return } UserDefaults.standard.set(text, forKey: MerchantMockDataManager.customerIdStorageKey) @@ -818,11 +849,11 @@ class MerchantSessionAndSettingsViewController: UIViewController { } extension MerchantSessionAndSettingsViewController: UIPickerViewDataSource, UIPickerViewDelegate { - + func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1 } - + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { if pickerView == testScenarioPicker { return Test.Scenario.allCases.count + 1 @@ -831,33 +862,35 @@ extension MerchantSessionAndSettingsViewController: UIPickerViewDataSource, UIPi } else if pickerView == test3DSScenarioPicker { return Test.Params.ThreeDS.Scenario.allCases.count + 1 } - + return 0 } - - func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) + -> String? + { if row == 0 { return "-" } else { if pickerView == testScenarioPicker { - return Test.Scenario.allCases[row-1].rawValue + return Test.Scenario.allCases[row - 1].rawValue } else if pickerView == testFailureFlowPicker { - return Test.Flow.allCases[row-1].rawValue + return Test.Flow.allCases[row - 1].rawValue } else if pickerView == test3DSScenarioPicker { - return Test.Params.ThreeDS.Scenario.allCases[row-1].rawValue + return Test.Params.ThreeDS.Scenario.allCases[row - 1].rawValue } } - + return nil } - + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { if pickerView == testScenarioPicker { if row == 0 { selectedTestScenario = nil testScenarioTextField.text = "-" } else { - selectedTestScenario = Test.Scenario.allCases[row-1] + selectedTestScenario = Test.Scenario.allCases[row - 1] testScenarioTextField.text = selectedTestScenario?.rawValue } } else if pickerView == testFailureFlowPicker { @@ -865,7 +898,7 @@ extension MerchantSessionAndSettingsViewController: UIPickerViewDataSource, UIPi selectedTestFlow = nil testFailureFlowTextField.text = "-" } else { - selectedTestFlow = Test.Flow.allCases[row-1] + selectedTestFlow = Test.Flow.allCases[row - 1] testFailureFlowTextField.text = selectedTestFlow?.rawValue } } else if pickerView == test3DSScenarioPicker { @@ -873,11 +906,11 @@ extension MerchantSessionAndSettingsViewController: UIPickerViewDataSource, UIPi selectedTest3DSScenario = nil test3DSScenarioTextField.text = "-" } else { - selectedTest3DSScenario = Test.Params.ThreeDS.Scenario.allCases[row-1] + selectedTest3DSScenario = Test.Params.ThreeDS.Scenario.allCases[row - 1] test3DSScenarioTextField.text = selectedTest3DSScenario?.rawValue } } - + render() } } @@ -886,7 +919,7 @@ extension MerchantSessionAndSettingsViewController { private func updateUI(for config: SessionConfiguration) { apiKeyTextField.text = config.customApiKey customerIdTextField.text = config.customerId.isEmpty ? "ios-customer-id" : config.customerId - + switch config.env { case .dev: environmentSegmentedControl.selectedSegmentIndex = 0 @@ -900,28 +933,27 @@ extension MerchantSessionAndSettingsViewController { environmentSegmentedControl.selectedSegmentIndex = 2 } environment = config.env - + switch config.paymentHandling { case .auto: checkoutFlowSegmentedControl.selectedSegmentIndex = 0 case .manual: checkoutFlowSegmentedControl.selectedSegmentIndex = 1 } - + currencyTextField.text = config.currency countryCodeTextField.text = config.countryCode - - let lineItem = ClientSessionRequestBody.Order.LineItem(itemId: "ld-lineitem", - description: "Fancy Shoes", - amount: Int(config.value) ?? 100, - quantity: 1, - discountAmount: nil, - taxAmount: nil) - + + let lineItem = ClientSessionRequestBody.Order.LineItem( + itemId: "ld-lineitem", + description: "Fancy Shoes", + amount: Int(config.value) ?? 100, + quantity: 1, + discountAmount: nil, + taxAmount: nil) + self.lineItems = [lineItem] - + metadataTextField.text = config.metadata - useNewWorkflows = config.newWorkflows - useNewWorkflowsSwitch.isOn = config.newWorkflows } } diff --git a/Debug App/Sources/View Controllers/New UI/StripeAchHeadless/MerchantHeadlessCheckoutStripeAchViewController+StripeAch.swift b/Debug App/Sources/View Controllers/New UI/StripeAchHeadless/MerchantHeadlessCheckoutStripeAchViewController+StripeAch.swift index 35c2dcbfd0..cda8a61d69 100644 --- a/Debug App/Sources/View Controllers/New UI/StripeAchHeadless/MerchantHeadlessCheckoutStripeAchViewController+StripeAch.swift +++ b/Debug App/Sources/View Controllers/New UI/StripeAchHeadless/MerchantHeadlessCheckoutStripeAchViewController+StripeAch.swift @@ -88,7 +88,7 @@ extension MerchantHeadlessCheckoutStripeAchViewController: PrimerHeadlessUnivers func primerHeadlessUniversalCheckoutDidFail(withError err: any Error, checkoutData: PrimerCheckoutData?) { print("\n\nMERCHANT APP\n\(#function)\nerror: \(err)\ncheckoutData: \(String(describing: checkoutData))") logs.append(#function) - presentResultsVC(checkoutData: nil, error: err) + presentResultsVC(checkoutData: checkoutData, error: err) } func primerHeadlessUniversalCheckoutDidReceiveAdditionalInfo(_ additionalInfo: PrimerCheckoutAdditionalInfo?) { diff --git a/Debug App/Tests/AppetizeConfigTests.swift b/Debug App/Tests/AppetizeConfigTests.swift index 818dd6f950..835f9451fd 100644 --- a/Debug App/Tests/AppetizeConfigTests.swift +++ b/Debug App/Tests/AppetizeConfigTests.swift @@ -79,7 +79,6 @@ private extension SessionConfiguration { city: "mock-city", postalCode: "mock-postalCode", vault: false, - newWorkflows: false, environment: "mock-environment", customApiKey: "mock-customApiKey", metadata: "mock-metadata") diff --git a/Debug App/Tests/MetadataParserTests.swift b/Debug App/Tests/MetadataParserTests.swift index 907624e950..61f6ed38f3 100644 --- a/Debug App/Tests/MetadataParserTests.swift +++ b/Debug App/Tests/MetadataParserTests.swift @@ -13,29 +13,6 @@ final class MetadataParserTests: XCTestCase { private let metadataParser = MetadataParser() - func testParseMetadataWithJSON() throws { - let jsonMetadata = #"{"key1": "value1", "key2": 123}"# - - let result = metadataParser.parse(jsonMetadata) - - XCTAssertEqual(result["key1"] as? String, "value1") - XCTAssertEqual(result["key2"] as? Int, 123) - } - - func testParseMetadataWithKeyValuePairs() throws { - let keyValueMetadata = """ - key1=value1 - doubleKey=123.0 - intKey=123 - """ - - let result = metadataParser.parse(keyValueMetadata) - - XCTAssertEqual(result["key1"] as? String, "value1") - XCTAssertEqual(result["doubleKey"] as? Double, 123) - XCTAssertEqual(result["intKey"] as? Int, 123) - } - func testParseMetadataWithInvalidMetadata() throws { let invalidMetadata = "invalid json" diff --git a/Sources/PrimerSDK/Classes/Core/Constants/Colors.swift b/Sources/PrimerSDK/Classes/Core/Constants/Colors.swift index 46693e5a52..08028a4e30 100644 --- a/Sources/PrimerSDK/Classes/Core/Constants/Colors.swift +++ b/Sources/PrimerSDK/Classes/Core/Constants/Colors.swift @@ -1,6 +1,7 @@ // MARK: Light import UIKit +import SwiftUI public struct PrimerColors { @@ -36,3 +37,28 @@ public struct PrimerColors { static let klarnaPink = UIColor(red: 1, green: 0.702, blue: 0.78, alpha: 1) static let blurredBackground = UIColor.black.withAlphaComponent(0.4) } + +// Extension to produce a SwiftUI Color which works until iOS 13 +extension PrimerColors { + static func swiftColor(_ uiColor: UIColor) -> Color { + if #available(iOS 14.0, *) { + return Color(uiColor) + } else { + // For iOS 13, we need to manually convert using components + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return Color( + .sRGB, + red: Double(red), + green: Double(green), + blue: Double(blue), + opacity: Double(alpha) + ) + } + } +} diff --git a/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift b/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift index 0410d9d211..068cb0226f 100644 --- a/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift +++ b/Sources/PrimerSDK/Classes/Core/Payment Services/CreateResumePaymentService.swift @@ -9,6 +9,9 @@ import Foundation internal protocol CreateResumePaymentServiceProtocol { func createPayment(paymentRequest: Request.Body.Payment.Create) -> Promise + func completePayment(clientToken: DecodedJWTToken, + completeUrl: URL, + body: Request.Body.Payment.Complete) -> Promise func resumePaymentWithPaymentId(_ paymentId: String, paymentResumeRequest: Request.Body.Payment.Resume) -> Promise } @@ -97,4 +100,34 @@ internal class CreateResumePaymentService: CreateResumePaymentServiceProtocol { } } } + + /** + * Completes a payment using the provided JWT token and URL. + * + * This private method performs an API call to complete a payment, using a decoded JWT token for authentication + * and a URL indicating where the completion request should be sent. + * + * - Parameters: + * - clientToken: A `DecodedJWTToken` representing the client's authentication token. + * - completeUrl: An `URL` indicating the endpoint for completing the ACH payment. + * + * - Returns: A `Promise` that resolves if the payment is completed successfully, or rejects if there is + * an error during the API call. + */ + func completePayment(clientToken: DecodedJWTToken, + completeUrl: URL, + body: Request.Body.Payment.Complete) -> Promise { + return Promise { seal in + self.apiClient.completePayment(clientToken: clientToken, + url: completeUrl, + paymentRequest: body) { result in + switch result { + case .success: + seal.fulfill() + case .failure(let error): + seal.reject(error) + } + } + } + } } diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Services/ACHTokenizationService.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Services/ACHTokenizationService.swift index efb8d571b4..c2bbf9e4b0 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Services/ACHTokenizationService.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Services/ACHTokenizationService.swift @@ -71,37 +71,37 @@ class ACHTokenizationService: ACHTokenizationDelegate, ACHValidationDelegate { else { throw ACHHelpers.getInvalidTokenError() } - + guard paymentMethod.id != nil else { throw ACHHelpers.getInvalidValueError( key: "configuration.id", value: paymentMethod.id ) } - + if AppState.current.amount == nil { throw ACHHelpers.getInvalidSettingError(name: "amount") } - + if AppState.current.currency == nil { throw ACHHelpers.getInvalidSettingError(name: "currency") } - + let lineItems = clientSession?.order?.lineItems ?? [] if lineItems.isEmpty { throw ACHHelpers.getInvalidValueError(key: "lineItems") } - + if !(lineItems.filter({ $0.amount == nil })).isEmpty { throw ACHHelpers.getInvalidValueError(key: "settings.orderItems") } - + guard let publishableKey = PrimerSettings.current.paymentMethodOptions.stripeOptions?.publishableKey, !publishableKey.isEmpty else { throw ACHHelpers.getInvalidValueError(key: "stripeOptions.publishableKey") } - + do { _ = try PrimerSettings.current.paymentMethodOptions.validSchemeForUrlScheme() } catch let error { diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Stripe/Component/StripeAchTokenizationViewModel.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Stripe/Component/StripeAchTokenizationViewModel.swift index 0acc423b25..82e3cff5be 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Stripe/Component/StripeAchTokenizationViewModel.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Composable/ACH/Stripe/Component/StripeAchTokenizationViewModel.swift @@ -165,7 +165,9 @@ class StripeAchTokenizationViewModel: PaymentMethodTokenizationViewModel { return self.awaitUserInput() } .then { () -> Promise in - return self.completePayment(clientToken: decodedJWTToken, completeUrl: sdkCompleteUrl) + return self.createResumePaymentService.completePayment(clientToken: decodedJWTToken, + completeUrl: sdkCompleteUrl, + body: StripeAchTokenizationViewModel.defaultCompleteBodyWithTimestamp) } .done { seal.fulfill(nil) @@ -326,36 +328,6 @@ class StripeAchTokenizationViewModel: PaymentMethodTokenizationViewModel { } } } - - /** - * Completes a payment using the provided JWT token and URL. - * - * This private method performs an API call to complete a payment, using a decoded JWT token for authentication - * and a URL indicating where the completion request should be sent. - * - * - Parameters: - * - clientToken: A `DecodedJWTToken` representing the client's authentication token. - * - completeUrl: An `URL` indicating the endpoint for completing the ACH payment. - * - * - Returns: A `Promise` that resolves if the payment is completed successfully, or rejects if there is - * an error during the API call. - */ - private func completePayment(clientToken: DecodedJWTToken, completeUrl: URL) -> Promise { - return Promise { seal in - let apiClient: PrimerAPIClientAchProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() - let timeZone = TimeZone(abbreviation: "UTC") - let timeStamp = Date().toString(timeZone: timeZone) - let body = Request.Body.Payment.Complete(mandateSignatureTimestamp: timeStamp, paymentMethodId: returnedStripeAchPaymentId) - apiClient.completePayment(clientToken: clientToken, url: completeUrl, paymentRequest: body) { result in - switch result { - case .success: - seal.fulfill() - case .failure(let error): - seal.reject(error) - } - } - } - } } // MARK: Drop-In @@ -425,6 +397,13 @@ extension StripeAchTokenizationViewModel: ACHUserDetailsDelegate { } } } + + static var defaultCompleteBodyWithTimestamp: Request.Body.Payment.Complete { + let timeZone = TimeZone(abbreviation: "UTC") + let timeStamp = Date().toString(timeZone: timeZone) + + return Request.Body.Payment.Complete(mandateSignatureTimestamp: timeStamp) + } } // MARK: Headless diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/PrimerPaymentMethodManager.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/PrimerPaymentMethodManager.swift index d65c5ec328..b746ea2e6a 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/PrimerPaymentMethodManager.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/PrimerPaymentMethodManager.swift @@ -12,6 +12,7 @@ public enum PrimerPaymentMethodManagerCategory: String { case rawData = "RAW_DATA" case nolPay = "NOL_PAY" case componentWithRedirect = "COMPONENT_WITH_REDIRECT" + case stripeAch = "STRIPE_ACH" } internal protocol PrimerPaymentMethodManager { diff --git a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/VaultManager.swift b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/VaultManager.swift index bfd602a91d..b635a2a0af 100644 --- a/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/VaultManager.swift +++ b/Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/VaultManager.swift @@ -25,7 +25,6 @@ extension PrimerHeadlessUniversalCheckout { private(set) var resumePaymentId: String? private var webViewController: SFSafariViewController? private var webViewCompletion: ((_ authorizationToken: String?, _ error: PrimerError?) -> Void)? - private var achMandateCompletion: ((Result) -> Void)? lazy var createResumePaymentService: CreateResumePaymentServiceProtocol = { CreateResumePaymentService(paymentMethodType: paymentMethodType) @@ -516,13 +515,9 @@ extension PrimerHeadlessUniversalCheckout { } firstly { - sendAdditionalInfoEvent() - } - .then { () -> Promise in - return self.awaitShowMandateResponse() - } - .then { () -> Promise in - return self.completePayment(clientToken: decodedJWTToken, completeUrl: sdkCompleteUrl) + self.createResumePaymentService.completePayment(clientToken: decodedJWTToken, + completeUrl: sdkCompleteUrl, + body: StripeAchTokenizationViewModel.defaultCompleteBodyWithTimestamp) } .done { seal.fulfill(nil) @@ -813,111 +808,6 @@ extension PrimerHeadlessUniversalCheckout { } } } - - /** - * Completes a payment using the provided JWT token and URL. - * - * This private method performs an API call to complete a payment, using a decoded JWT token for authentication - * and a URL indicating where the completion request should be sent. - * - * - Parameters: - * - clientToken: A `DecodedJWTToken` representing the client's authentication token. - * - completeUrl: An `URL` indicating the endpoint for completing the ACH payment. - * - * - Returns: A `Promise` that resolves if the payment is completed successfully, or rejects if there is - * an error during the API call. - */ - private func completePayment(clientToken: DecodedJWTToken, completeUrl: URL) -> Promise { - return Promise { seal in - let apiClient: PrimerAPIClientAchProtocol = PrimerAPIConfigurationModule.apiClient ?? PrimerAPIClient() - let timeZone = TimeZone(abbreviation: "UTC") - let timeStamp = Date().toString(timeZone: timeZone) - - let body = Request.Body.Payment.Complete(mandateSignatureTimestamp: timeStamp) - apiClient.completePayment(clientToken: clientToken, url: completeUrl, paymentRequest: body) { result in - switch result { - case .success: - seal.fulfill() - case .failure(let error): - seal.reject(error) - } - } - } - } - - /** - * Sends additional information via delegate `PrimerHeadlessUniversalCheckoutDelegate` if implemented in the headless checkout context. - * - * This private method checks if the checkout is being conducted in a headless mode and whether the delegate - * for handling additional information is implemented. It ensures that additional information events are only - * sent if the delegate and its respective method are available, otherwise, it handles the absence of the delegate - * method by logging an error and rejecting the promise. - * - * - Returns: A promise that resolves if additional information is successfully handled or sent, or rejects if - * there are issues with the delegate implementation or if the delegate method is not implemented. - */ - private func sendAdditionalInfoEvent() -> Promise { - return Promise { seal in - guard PrimerHeadlessUniversalCheckout.current.delegate != nil else { - seal.fulfill() - return - } - - let delegate = PrimerHeadlessUniversalCheckout.current.delegate - let isAdditionalInfoImplemented = delegate?.primerHeadlessUniversalCheckoutDidReceiveAdditionalInfo != nil - - guard isAdditionalInfoImplemented else { - let logMessage = - """ - Delegate function 'primerHeadlessUniversalCheckoutDidReceiveAdditionalInfo(_ additionalInfo: PrimerCheckoutAdditionalInfo?)'\ - hasn't been implemented. No events will be sent to your delegate instance. - """ - logger.warn(message: logMessage) - - let message = "Couldn't continue due to unimplemented delegate method `primerHeadlessUniversalCheckoutDidReceiveAdditionalInfo`" - let error = PrimerError.unableToPresentPaymentMethod(paymentMethodType: self.paymentMethodType, - userInfo: .errorUserInfoDictionary(additionalInfo: [ - "message": message - ]), - diagnosticsId: UUID().uuidString) - - seal.reject(error) - return - } - - let additionalInfo = ACHMandateAdditionalInfo() - PrimerDelegateProxy.primerDidReceiveAdditionalInfo(additionalInfo) - seal.fulfill() - } - } - - /** - * Waits for a response from the ACHMandateDelegate method. - * The response is returned in stripeMandateCompletion handler. - */ - private func awaitShowMandateResponse() -> Promise { - return Promise { seal in - self.achMandateCompletion = { result in - switch result { - case .success: - seal.fulfill() - case .failure(let error): - seal.reject(error) - } - } - } - } - } -} - -extension PrimerHeadlessUniversalCheckout.VaultManager: ACHMandateDelegate { - public func acceptMandate() { - achMandateCompletion?(.success(())) - } - - public func declineMandate() { - let error = ACHHelpers.getCancelledError(paymentMethodType: paymentMethodType) - achMandateCompletion?(.failure(error)) } } diff --git a/Sources/PrimerSDK/Classes/Data Models/ImageName.swift b/Sources/PrimerSDK/Classes/Data Models/ImageName.swift index 32d1c982af..5696d7bef9 100644 --- a/Sources/PrimerSDK/Classes/Data Models/ImageName.swift +++ b/Sources/PrimerSDK/Classes/Data Models/ImageName.swift @@ -28,6 +28,7 @@ public enum ImageName: String { lock, rightArrow, bank, + achBank, camera, error, klarna, diff --git a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift index 2b268e63d7..269e6e7f1d 100644 --- a/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift +++ b/Sources/PrimerSDK/Classes/Data Models/PrimerPaymentMethod.swift @@ -238,7 +238,7 @@ class PrimerPaymentMethod: Codable, LogReporter { categories.append(PrimerPaymentMethodManagerCategory.componentWithRedirect) case .stripeAch: - categories.append(PrimerPaymentMethodManagerCategory.nativeUI) + categories.append(PrimerPaymentMethodManagerCategory.stripeAch) case .adyenBlik: categories.append(PrimerPaymentMethodManagerCategory.rawData) diff --git a/Sources/PrimerSDK/Classes/Data Models/TokenizationResponse.swift b/Sources/PrimerSDK/Classes/Data Models/TokenizationResponse.swift index c73a8a8c5c..29f9d019e8 100644 --- a/Sources/PrimerSDK/Classes/Data Models/TokenizationResponse.swift +++ b/Sources/PrimerSDK/Classes/Data Models/TokenizationResponse.swift @@ -67,6 +67,7 @@ extension Response.Body.Tokenization { case .payPalBillingAgreement: return .paypal2 case .goCardlessMandate: return .bank case .klarnaCustomerToken: return .klarna + case .stripeAch: return .achBank default: return .creditCard } } @@ -108,6 +109,14 @@ extension Response.Body.Tokenization { expiry: "", imageName: self.icon, paymentMethodType: self.paymentInstrumentType) + case .stripeAch: + return CardButtonViewModel( + network: self.paymentInstrumentData?.bankName ?? "Bank account", + cardholder: "•••• \(self.paymentInstrumentData?.accountNumberLastFourDigits ?? "")", + last4: "", + expiry: "", + imageName: self.icon, + paymentMethodType: self.paymentInstrumentType) default: return nil } @@ -144,6 +153,9 @@ extension Response.Body.Tokenization { public let paymentMethodType: String? public let sessionInfo: SessionInfo? + public let bankName: String? + public let accountNumberLastFourDigits: String? + // swiftlint:disable:next nesting public struct SessionInfo: Codable { public let locale: String? diff --git a/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift b/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift index 8795061adf..ba2a51caac 100644 --- a/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift +++ b/Sources/PrimerSDK/Classes/Services/Network/Protocols/PrimerAPIClientCreateResumePaymentProtocol.swift @@ -18,4 +18,10 @@ protocol PrimerAPIClientCreateResumePaymentProtocol { paymentId: String, paymentResumeRequest: Request.Body.Payment.Resume, completion: @escaping APICompletion) + + func completePayment( + clientToken: DecodedJWTToken, + url: URL, + paymentRequest: Request.Body.Payment.Complete, + completion: @escaping APICompletion) } diff --git a/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateView.swift b/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateView.swift index b0e8f1a8b9..3c9ddcdc34 100644 --- a/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateView.swift +++ b/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateView.swift @@ -64,6 +64,7 @@ struct ACHMandateView: View { .disabled(viewModel.shouldDisableViews) .padding([.horizontal, .bottom]) } + .background(PrimerColors.swiftColor(PrimerColors.white)) } } diff --git a/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateViewController.swift b/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateViewController.swift index 5e2edc6216..bfcb47fc0e 100644 --- a/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/ACH Mandate Sheet/ACHMandateViewController.swift @@ -30,7 +30,7 @@ class ACHMandateViewController: PrimerViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white + view.backgroundColor = PrimerColors.white addMandateView() } diff --git a/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsView.swift b/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsView.swift index 13b861eb8e..61531f777b 100644 --- a/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsView.swift +++ b/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsView.swift @@ -16,7 +16,7 @@ struct ACHUserDetailsView: View { var onBackPressed: () -> Void var body: some View { - + VStack { ZStack { Button { onBackPressed() @@ -41,9 +41,8 @@ struct ACHUserDetailsView: View { .font(.system(size: 18, weight: .medium)) .addAccessibilityIdentifier(identifier: AccessibilityIdentifier.StripeAchUserDetailsComponent.title.rawValue) } - .padding(.top, -3) + .background(PrimerColors.swiftColor(PrimerColors.white)) - VStack { HStack { Text(Strings.UserDetails.subtitle) .font(.system(size: 17)) @@ -81,13 +80,19 @@ struct ACHUserDetailsView: View { Spacer() Button(action: submitAction) { - Text(Strings.UserDetails.continueButton) - .font(.system(size: 17, weight: .medium)) - .foregroundColor(viewModel.isValidForm ? Color.white : Color.gray) - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.isValidForm ? Color.blue : Color.gray.opacity(0.2)) - .cornerRadius(10) + ZStack { + if viewModel.shouldDisableViews { + ActivityIndicator(isAnimating: .constant(true), style: .medium, color: UIColor.black) + } else { + Text(Strings.UserDetails.continueButton) + .font(.system(size: 17, weight: .medium)) + } + } + .foregroundColor(viewModel.isValidForm ? Color.white : Color.gray) + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.isValidForm ? Color.blue : Color.gray.opacity(0.2)) + .cornerRadius(10) } .disabled(!viewModel.isValidForm) .padding(.horizontal) @@ -95,6 +100,7 @@ struct ACHUserDetailsView: View { } .disabled(viewModel.shouldDisableViews) .frame(height: 380) + .background(PrimerColors.swiftColor(PrimerColors.white)) } private func submitAction() { @@ -121,7 +127,7 @@ struct CustomTextFieldView: View { TextField("", text: $text) .padding(.horizontal, 10) .frame(height: 44) - .background(Color.white) + .background(PrimerColors.swiftColor(PrimerColors.white)) .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) diff --git a/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsViewController.swift b/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsViewController.swift index ca0aff79b7..29b4701914 100644 --- a/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/ACH UserDetails Sheet/ACHUserDetailsViewController.swift @@ -41,7 +41,7 @@ class ACHUserDetailsViewController: PrimerViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white + view.backgroundColor = PrimerColors.white addStripeFormView() setupStripeACHDelegatesAndStart() } diff --git a/Sources/PrimerSDK/Classes/User Interface/Components/PrimerCustomResult/PrimerResultPaymentStatusView.swift b/Sources/PrimerSDK/Classes/User Interface/Components/PrimerCustomResult/PrimerResultPaymentStatusView.swift index b9b919f869..840e158523 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Components/PrimerCustomResult/PrimerResultPaymentStatusView.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Components/PrimerCustomResult/PrimerResultPaymentStatusView.swift @@ -68,5 +68,6 @@ struct PrimerResultPaymentStatusView: View { } } .padding(.horizontal) + .background(PrimerColors.swiftColor(PrimerColors.white)) } } diff --git a/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift b/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift index b849486d72..9bc542cabe 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Primer/CardButton.swift @@ -15,30 +15,20 @@ internal class CardButton: PrimerButton { private var last4Label = UILabel() private var expiryLabel = UILabel() private var border = PrimerView() - private var checkView = UIImageView() - private weak var checkmarkViewWidthConstraint: NSLayoutConstraint? - private weak var checkmarkViewTrailingConstraint: NSLayoutConstraint? - private weak var checkmarkViewLeadingConstraint: NSLayoutConstraint? - private weak var checkmarkViewHeightConstraint: NSLayoutConstraint? - - var showIcon = true - - func render(model: CardButtonViewModel?, showIcon: Bool = true) { + func render(model: CardButtonViewModel?) { guard let model = model else { return } accessibilityIdentifier = "saved_payment_method_button" let theme: PrimerThemeProtocol = DependencyContainer.resolve() backgroundColor = theme.paymentMethodButton.color(for: .enabled) - addCheckmarkView() - if showIcon { - + if model.paymentMethodType == .paymentCard || model.paymentMethodType == .cardOffSession { + addCardIcon(image: CardNetwork(cardNetworkStr: model.network).icon) } else { - toggleIcon() + addCardIcon(image: model.imageName.image) } - addCardIcon(image: CardNetwork(cardNetworkStr: model.network).icon) addBorder() switch model.paymentMethodType { @@ -95,10 +85,13 @@ internal class CardButton: PrimerButton { iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 17).isActive = true iconView.heightAnchor.constraint(equalToConstant: 24).isActive = true iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true + } else if iconView.image == ImageName.achBank.image { + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true + iconView.widthAnchor.constraint(equalToConstant: 56).isActive = true } else { iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true - iconView.heightAnchor.constraint(equalToConstant: 28).isActive = true - iconView.widthAnchor.constraint(equalToConstant: 38).isActive = true + iconView.heightAnchor.constraint(equalToConstant: 41).isActive = true + iconView.widthAnchor.constraint(equalToConstant: 56).isActive = true } } @@ -155,9 +148,7 @@ internal class CardButton: PrimerButton { last4Label.textColor = theme.paymentMethodButton.text.color addSubview(last4Label) last4Label.translatesAutoresizingMaskIntoConstraints = false - checkmarkViewLeadingConstraint = last4Label.trailingAnchor - .constraint(equalTo: checkView.leadingAnchor, constant: -14) - checkmarkViewLeadingConstraint?.isActive = true + last4Label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14).isActive = true last4Label.bottomAnchor.constraint(equalTo: centerYAnchor).isActive = true } @@ -186,40 +177,6 @@ internal class CardButton: PrimerButton { border.isUserInteractionEnabled = false } - private func addCheckmarkView() { - let theme: PrimerThemeProtocol = DependencyContainer.resolve() - checkView = UIImageView(image: ImageName.check2.image) - - // color - let tintedIcon = ImageName.check2.image?.withRenderingMode(.alwaysTemplate) - checkView.tintColor = theme.paymentMethodButton.text.color - checkView.image = tintedIcon - - addSubview(checkView) - checkView.translatesAutoresizingMaskIntoConstraints = false - checkView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - checkmarkViewTrailingConstraint = checkView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14) - checkmarkViewWidthConstraint = checkView.widthAnchor.constraint(equalToConstant: 14) - checkmarkViewTrailingConstraint?.isActive = true - checkmarkViewWidthConstraint?.isActive = true - checkmarkViewHeightConstraint = checkView.heightAnchor.constraint(equalToConstant: 22) - checkmarkViewHeightConstraint?.isActive = true - } - - func showDeleteIcon(_ flag: Bool) { - checkView.image = flag ? ImageName.delete.image : ImageName.check2.image - } - - func toggleIcon() { - checkmarkViewTrailingConstraint?.constant = showIcon ? -14 : 0 - checkmarkViewWidthConstraint?.constant = showIcon ? 14 : 0 - checkmarkViewHeightConstraint?.constant = showIcon ? 14 : 0 - } - - func showCheckmarkIcon(_ val: Bool) { - checkView.isHidden = !val - } - func hideBorder() { border.isHidden = true } diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerContainerViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerContainerViewController.swift index 32151d49b3..0a8027d00c 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerContainerViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerContainerViewController.swift @@ -30,6 +30,7 @@ class PrimerContainerViewController: PrimerViewController { override func viewDidLoad() { super.viewDidLoad() + self.view.accessibilityIdentifier = "checkout_sheet_content" scrollView.accessibilityIdentifier = "primer_container_scroll_view" view.addSubview(mockedNavigationBar) diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerFormViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerFormViewController.swift index d5ef3658e2..1b807ea378 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerFormViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerFormViewController.swift @@ -116,7 +116,7 @@ class PrimerFormViewController: PrimerViewController { paymentMethodsStack.addArrangedSubview(unknownFeesContainerView) } - paymentMethodsContainerStack.addArrangedSubview( paymentMethodsStack) + paymentMethodsContainerStack.addArrangedSubview(paymentMethodsStack) stackView.addArrangedSubview(paymentMethodsContainerStack) } } diff --git a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift index bca7939ffc..745f99bd30 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Root/PrimerUniversalCheckoutViewController.swift @@ -197,7 +197,7 @@ internal class PrimerUniversalCheckoutViewController: PrimerFormViewController { savedCardView.backgroundColor = .white savedCardView.translatesAutoresizingMaskIntoConstraints = false savedCardView.heightAnchor.constraint(equalToConstant: 64.0).isActive = true - savedCardView.render(model: cardButtonViewModel, showIcon: false) + savedCardView.render(model: cardButtonViewModel) paymentMethodStackView.addArrangedSubview(savedCardView) } diff --git a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift index 3ba507145b..c90d77c5e1 100644 --- a/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift +++ b/Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/CheckoutWithVaultedPaymentMethodViewModel.swift @@ -61,6 +61,8 @@ class CheckoutWithVaultedPaymentMethodViewModel: LogReporter { .done { checkoutData in if let checkoutData = checkoutData { PrimerDelegateProxy.primerDidCompleteCheckoutWithData(checkoutData) + } else if let checkoutData = self.paymentCheckoutData { + PrimerDelegateProxy.primerDidCompleteCheckoutWithData(checkoutData) } self.handleSuccessfulFlow() @@ -461,7 +463,34 @@ Make sure you call the decision handler otherwise the SDK will hang. private func handleDecodedClientTokenIfNeeded(_ decodedJWTToken: DecodedJWTToken, paymentMethodTokenData: PrimerPaymentMethodTokenData) -> Promise { return Promise { seal in - if decodedJWTToken.intent == RequiredActionName.threeDSAuthentication.rawValue { + + if decodedJWTToken.intent?.contains("STRIPE_ACH") == true { + if let sdkCompleteUrlString = decodedJWTToken.sdkCompleteUrl, + let sdkCompleteUrl = URL(string: sdkCompleteUrlString) { + + DispatchQueue.main.async { + PrimerUIManager.primerRootViewController?.enableUserInteraction(true) + } + + firstly { + self.createResumePaymentService.completePayment(clientToken: decodedJWTToken, + completeUrl: sdkCompleteUrl, + body: StripeAchTokenizationViewModel.defaultCompleteBodyWithTimestamp) + } + .done { + seal.fulfill(nil) + } + .catch { err in + seal.reject(err) + } + + } else { + let err = PrimerError.invalidClientToken(userInfo: .errorUserInfoDictionary(), + diagnosticsId: UUID().uuidString) + ErrorHandler.handle(error: err) + seal.reject(err) + } + } else if decodedJWTToken.intent == RequiredActionName.threeDSAuthentication.rawValue { let threeDSService = ThreeDSService() threeDSService.perform3DS( @@ -495,9 +524,14 @@ Make sure you call the decision handler otherwise the SDK will hang. } func handleSuccessfulFlow() { - let categories = self.config.paymentMethodManagerCategories - PrimerUIManager.dismissOrShowResultScreen(type: .success, - paymentMethodManagerCategories: categories ?? []) + if let paymentMethodType = config.internalPaymentMethodType, + paymentMethodType == .stripeAch { + PrimerUIManager.showResultScreen(for: paymentMethodType, error: nil) + } else { + let categories = self.config.paymentMethodManagerCategories + PrimerUIManager.dismissOrShowResultScreen(type: .success, + paymentMethodManagerCategories: categories ?? []) + } } func handleFailureFlow(errorMessage: String?) { diff --git a/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift b/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift index 05cad32273..c91937bbea 100644 --- a/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift +++ b/Sources/PrimerSDK/Classes/User Interface/Vault/VaultPaymentMethodViewController.swift @@ -47,8 +47,8 @@ internal class VaultedPaymentInstrumentCell: UITableViewCell { if cardNetworkImageView.superview == nil { horizontalStackView.addArrangedSubview(cardNetworkImageView) cardNetworkImageView.translatesAutoresizingMaskIntoConstraints = false - cardNetworkImageView.widthAnchor.constraint(equalToConstant: 28).isActive = true - cardNetworkImageView.heightAnchor.constraint(equalToConstant: 38).isActive = true + cardNetworkImageView.widthAnchor.constraint(equalToConstant: 41).isActive = true + cardNetworkImageView.heightAnchor.constraint(equalToConstant: 56).isActive = true } if verticalLeftStackView.superview == nil { @@ -109,12 +109,18 @@ internal class VaultedPaymentInstrumentCell: UITableViewCell { verticalRightStackView.distribution = .fillEqually verticalRightStackView.spacing = 0 - if let network = paymentMethod.cardButtonViewModel?.network { - let cardNetworkImage = CardNetwork(cardNetworkStr: network).icon - cardNetworkImageView.image = cardNetworkImage + if paymentMethod.cardButtonViewModel?.paymentMethodType == .paymentCard || paymentMethod.cardButtonViewModel?.paymentMethodType == .cardOffSession { + + if let network = paymentMethod.cardButtonViewModel?.network { + let cardNetworkImage = CardNetwork(cardNetworkStr: network).icon + cardNetworkImageView.image = cardNetworkImage + } else { + cardNetworkImageView.image = paymentMethod.cardButtonViewModel?.imageName.image + } } else { cardNetworkImageView.image = paymentMethod.cardButtonViewModel?.imageName.image } + cardNetworkImageView.contentMode = .scaleAspectFit checkmarkImageView.image = isDeleting ? diff --git a/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/Contents.json b/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/Contents.json new file mode 100644 index 0000000000..30d8580b65 --- /dev/null +++ b/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ach-bank.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/ach-bank.pdf b/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/ach-bank.pdf new file mode 100644 index 0000000000..43d42915d1 Binary files /dev/null and b/Sources/PrimerSDK/Resources/Icons.xcassets/Logos/achBank.imageset/ach-bank.pdf differ diff --git a/Tests/Primer/Managers/VaultManagerTests.swift b/Tests/Primer/Managers/VaultManagerTests.swift index ec200f98f8..67781ee64d 100644 --- a/Tests/Primer/Managers/VaultManagerTests.swift +++ b/Tests/Primer/Managers/VaultManagerTests.swift @@ -17,8 +17,6 @@ final class VaultManagerTests: XCTestCase { var tokenizationService: MockTokenizationService! var createResumePaymentService: MockCreateResumePaymentService! - - var mandateDelegate: ACHMandateDelegate? private var vaultService: MockVaultService! @@ -49,8 +47,6 @@ final class VaultManagerTests: XCTestCase { sut.tokenizationService = tokenizationService sut.createResumePaymentService = createResumePaymentService sut.vaultService = vaultService - - mandateDelegate = sut PrimerHeadlessUniversalCheckout.current.delegate = headlessCheckoutDelegate @@ -60,7 +56,6 @@ final class VaultManagerTests: XCTestCase { override func tearDownWithError() throws { sut = nil rawDataManagerDelegate = nil - mandateDelegate = nil SDKSessionHelper.tearDown() @@ -234,14 +229,6 @@ final class VaultManagerTests: XCTestCase { expectCreatePayment.fulfill() return self.paymentACHResponseBody } - - let expectDidReceiveMandateAdditionalInfo = self.expectation(description: "didReceiveMandateAdditionalInfo is called") - headlessCheckoutDelegate.onDidReceiveAdditionalInfo = { additionalInfo in - expectDidReceiveMandateAdditionalInfo.fulfill() - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.mandateDelegate?.acceptMandate() - } - } let expectDidCompleteCheckout = self.expectation(description: "Headless checkout completed") headlessCheckoutDelegate.onDidCompleteCheckoutWithData = { _ in @@ -257,7 +244,6 @@ final class VaultManagerTests: XCTestCase { wait(for: [ expectExchangeTokenData, expectCreatePayment, - expectDidReceiveMandateAdditionalInfo, expectDidCompleteCheckout ], timeout: 20.0, enforceOrder: true) } diff --git a/Tests/Primer/Services/CreateResumePaymentServiceTests.swift b/Tests/Primer/Services/CreateResumePaymentServiceTests.swift index 1a513d3486..60d984f65e 100644 --- a/Tests/Primer/Services/CreateResumePaymentServiceTests.swift +++ b/Tests/Primer/Services/CreateResumePaymentServiceTests.swift @@ -225,6 +225,31 @@ final class CreateResumePaymentServiceTests: XCTestCase { waitForExpectations(timeout: 5, handler: nil) } + func test_complete_token_completeUrl() throws { + let response = Response.Body.Complete.successResponse + let apiClient = MockCreateResumeAPI(completeResponse: response) + + let createResumeService = CreateResumePaymentService(paymentMethodType: "STRIPE_ACH", + apiClient: apiClient) + + AppState.current.clientToken = MockAppState.mockClientToken + let expectation = self.expectation(description: "Promise fulfilled") + + guard let clientToken = PrimerAPIConfigurationModule.decodedJWTToken else { + XCTFail() + return + } + + let _ = createResumeService.completePayment(clientToken: clientToken, + completeUrl: URL(string: "https://example.com")!, + body: StripeAchTokenizationViewModel.defaultCompleteBodyWithTimestamp + ).done { + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + } @@ -232,10 +257,14 @@ private class MockCreateResumeAPI: PrimerAPIClientCreateResumePaymentProtocol { var resumeResponse: APIResult? var createResponse: APIResult? + var completeResponse: APIResult? - init(resumeResponse: APIResult? = nil, createResponse: APIResult? = nil) { + init(resumeResponse: APIResult? = nil, + createResponse: APIResult? = nil, + completeResponse: APIResult? = nil) { self.resumeResponse = resumeResponse self.createResponse = createResponse + self.completeResponse = completeResponse } func createPayment(clientToken: DecodedJWTToken, paymentRequestBody: Request.Body.Payment.Create, completion: @escaping APICompletion) { @@ -253,6 +282,16 @@ private class MockCreateResumeAPI: PrimerAPIClientCreateResumePaymentProtocol { } completion(resumeResponse) } + + func completePayment(clientToken: DecodedJWTToken, + url: URL, paymentRequest: Request.Body.Payment.Complete, + completion: @escaping APICompletion) { + guard let completeResponse else { + XCTFail("No complete response set") + return + } + completion(completeResponse) + } } private extension Response.Body.Payment { @@ -324,3 +363,9 @@ private extension Response.Body.Payment { diagnosticsId: "")) } } + +private extension Response.Body.Complete { + static var successResponse: APIResult { + .success(.init()) + } +} diff --git a/Tests/Primer/v2/HeadlessVaultManagerTests.swift b/Tests/Primer/v2/HeadlessVaultManagerTests.swift index 661449b6aa..919ec06cb3 100644 --- a/Tests/Primer/v2/HeadlessVaultManagerTests.swift +++ b/Tests/Primer/v2/HeadlessVaultManagerTests.swift @@ -102,7 +102,9 @@ final class HeadlessVaultManagerTests: XCTestCase { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil), + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil), threeDSecureAuthentication: nil, token: "anything", tokenType: .multiUse, @@ -301,7 +303,9 @@ final class HeadlessVaultManagerTests: XCTestCase { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil), + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil), threeDSecureAuthentication: nil, token: "anything", tokenType: .multiUse, @@ -389,7 +393,9 @@ final class HeadlessVaultManagerTests: XCTestCase { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil), + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil), analyticsId: "analytics-id") ] @@ -466,7 +472,9 @@ final class HeadlessVaultManagerTests: XCTestCase { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil), + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil), analyticsId: "analytics-id") ] @@ -505,7 +513,9 @@ final class HeadlessVaultManagerTests: XCTestCase { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil), + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil), analyticsId: "analytics-id") ] diff --git a/Tests/Utilities/Mocks.swift b/Tests/Utilities/Mocks.swift index 9b3e4aacae..4aa1a423d9 100644 --- a/Tests/Utilities/Mocks.swift +++ b/Tests/Utilities/Mocks.swift @@ -74,7 +74,9 @@ class Mocks { productId: nil, paymentMethodConfigId: nil, paymentMethodType: nil, - sessionInfo: nil) + sessionInfo: nil, + bankName: nil, + accountNumberLastFourDigits: nil) static var payment = Response.Body.Payment( id: "mock_id", diff --git a/Tests/Utilities/Mocks/Services/CreateResumePaymentService.swift b/Tests/Utilities/Mocks/Services/CreateResumePaymentService.swift index 3bce02ca76..a1d3b559f8 100644 --- a/Tests/Utilities/Mocks/Services/CreateResumePaymentService.swift +++ b/Tests/Utilities/Mocks/Services/CreateResumePaymentService.swift @@ -9,6 +9,14 @@ import Foundation @testable import PrimerSDK class MockCreateResumePaymentService: CreateResumePaymentServiceProtocol { + func completePayment(clientToken: PrimerSDK.DecodedJWTToken, + completeUrl: URL, + body: Request.Body.Payment.Complete) -> PrimerSDK.Promise { + Promise { seal in + seal.fulfill() + } + } + static var apiClient: (any PrimerSDK.PrimerAPIClientProtocol)?