diff --git a/StripeCore/StripeCore/Source/Coder/ParsedEnum.swift b/StripeCore/StripeCore/Source/Coder/ParsedEnum.swift new file mode 100644 index 000000000000..b0195090deee --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/ParsedEnum.swift @@ -0,0 +1,84 @@ +// +// ParsedEnum.swift +// StripeCore +// +// Created by Jeremy Kelleher on 3/24/26. +// Copyright © 2026 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// A wrapper that pairs a parsed enum value with the raw API string. +/// +/// Known values: `value` is non-nil, `rawValue` matches the enum's raw value. +/// Unknown values: `value` is nil, `rawValue` contains the unrecognized API string. +/// :nodoc: +@_spi(STP) public struct ParsedEnum: Hashable { + /// The parsed enum value, or nil if the API string was unrecognized. + public let value: E? + /// The raw API string, always preserved. + public let rawValue: String + + /// Initialize from a known enum value. + public init(_ value: E) { + self.value = value + self.rawValue = value.rawValue + } + + /// Initialize from a raw API string, attempting to parse the enum. + public init(rawValue: String) { + self.rawValue = rawValue + self.value = E(rawValue: rawValue) + } + + /// True if the value was not recognized by the SDK. + public var isUnparsed: Bool { value == nil } + + // MARK: - Hashable / Equatable + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } +} + +// MARK: - Codable + +extension ParsedEnum: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self.init(rawValue: raw) + } +} + +extension ParsedEnum: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +extension ParsedEnum { + public static func == (lhs: Self, rhs: E) -> Bool { + lhs.value == rhs + } +} + +extension Set { + @_spi(STP) public func contains(_ enumValue: E) -> Bool + where Element == ParsedEnum + { + contains(ParsedEnum(enumValue)) + } + + @_spi(STP) @discardableResult + public mutating func insert(_ enumValue: E) -> (inserted: Bool, memberAfterInsert: ParsedEnum) + where Element == ParsedEnum + { + insert(ParsedEnum(enumValue)) + } +} diff --git a/StripeCore/StripeCore/Source/Coder/StripeCodable.swift b/StripeCore/StripeCore/Source/Coder/StripeCodable.swift index 03e9206a810d..cb16860e9c6e 100644 --- a/StripeCore/StripeCore/Source/Coder/StripeCodable.swift +++ b/StripeCore/StripeCore/Source/Coder/StripeCodable.swift @@ -50,6 +50,13 @@ public protocol SafeEnumDecodable: Decodable { /// :nodoc: public protocol SafeEnumCodable: Encodable, SafeEnumDecodable {} +/// A protocol for enums whose raw API string values should be preserved +/// even when the value is unknown to the SDK. +/// Unlike SafeEnumCodable, the raw string is never lost — use with `ParsedEnum`. +/// :nodoc: +@_spi(STP) public protocol SafeParsedEnumCodable: RawRepresentable, CaseIterable, Hashable + where RawValue == String {} + extension UnknownFieldsDecodable { /// A dictionary containing all response fields from the original JSON, /// including unknown fields. diff --git a/StripeCore/StripeCoreTests/API Bindings/ParsedEnumTests.swift b/StripeCore/StripeCoreTests/API Bindings/ParsedEnumTests.swift new file mode 100644 index 000000000000..a77140f2e1d2 --- /dev/null +++ b/StripeCore/StripeCoreTests/API Bindings/ParsedEnumTests.swift @@ -0,0 +1,110 @@ +// +// ParsedEnumTests.swift +// StripeCoreTests +// + +import Foundation +@_spi(STP)@testable import StripeCore +import XCTest + +private enum Color: String, SafeParsedEnumCodable { + case red = "RED" + case blue = "BLUE" +} + +private struct Container: Codable { + let color: ParsedEnum + let colors: [ParsedEnum] +} + +class ParsedEnumTests: XCTestCase { + + // MARK: - Decoding + + func test_decodesKnownValue() throws { + let json = #"{"color":"RED","colors":["RED","BLUE"]}"# + let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!) + XCTAssertEqual(result.color.value, .red) + XCTAssertEqual(result.color.rawValue, "RED") + } + + func test_decodesUnknownValue() throws { + let json = #"{"color":"GREEN","colors":[]}"# + let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!) + XCTAssertNil(result.color.value) + XCTAssertEqual(result.color.rawValue, "GREEN") + XCTAssertTrue(result.color.isUnparsed) + } + + func test_decodesArrayOfMixed() throws { + let json = #"{"color":"RED","colors":["RED","GREEN","BLUE"]}"# + let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!) + XCTAssertEqual(result.colors[0].value, .red) + XCTAssertNil(result.colors[1].value) + XCTAssertEqual(result.colors[1].rawValue, "GREEN") + XCTAssertEqual(result.colors[2].value, .blue) + } + + // MARK: - Encoding + + func test_encodesKnownValue() throws { + let container = Container(color: ParsedEnum(.red), colors: []) + let data = try JSONEncoder().encode(container) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(json["color"] as? String, "RED") + } + + func test_encodesUnknownValuePreservingRawString() throws { + let container = Container(color: ParsedEnum(rawValue: "GREEN"), colors: []) + let data = try JSONEncoder().encode(container) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(json["color"] as? String, "GREEN") + } + + // MARK: - Hashable / Equatable + + func test_equalityBasedOnRawValue() { + XCTAssertEqual(ParsedEnum(.red), ParsedEnum(rawValue: "RED")) + XCTAssertNotEqual(ParsedEnum(.red), ParsedEnum(.blue)) + XCTAssertNotEqual(ParsedEnum(.red), ParsedEnum(rawValue: "green")) + } + + func test_setDeduplicationByRawValue() { + let set: Set> = [ParsedEnum(.red), ParsedEnum(rawValue: "RED")] + XCTAssertEqual(set.count, 1) + } + + func test_unknownValuesHashableInSet() { + let set: Set> = [ParsedEnum(rawValue: "GREEN"), ParsedEnum(rawValue: "GREEN")] + XCTAssertEqual(set.count, 1) + } + + // MARK: - Convenience operators + + func test_comparisonWithEnumValue() { + let parsed = ParsedEnum(.red) + XCTAssertTrue(parsed == Color.red) + XCTAssertFalse(parsed == Color.blue) + } + + func test_comparisonWithUnknownValue() { + let parsed = ParsedEnum(rawValue: "GREEN") + XCTAssertFalse(parsed == Color.red) + XCTAssertFalse(parsed == Color.blue) + } + + // MARK: - Set convenience extension + + func test_setContainsEnumValue() { + let set: Set> = [ParsedEnum(.red), ParsedEnum(rawValue: "GREEN")] + XCTAssertTrue(set.contains(Color.red)) + XCTAssertFalse(set.contains(Color.blue)) + } + + func test_setInsertEnumValue() { + var set: Set> = [] + set.insert(Color.red) + XCTAssertTrue(set.contains(Color.red)) + XCTAssertEqual(set.count, 1) + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift index 2546b8bbdb86..6fe0fdc72e14 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift @@ -352,7 +352,7 @@ struct LinkPMDisplayDetails { } func listPaymentDetails( - supportedTypes: [ConsumerPaymentDetails.DetailsType], + supportedTypes: [ParsedEnum], shouldRetryOnAuthError: Bool = true ) async throws -> [ConsumerPaymentDetails] { return try await withCheckedThrowingContinuation { continuation in @@ -371,7 +371,7 @@ struct LinkPMDisplayDetails { } func listPaymentDetails( - supportedTypes: [ConsumerPaymentDetails.DetailsType], + supportedTypes: [ParsedEnum], shouldRetryOnAuthError: Bool = true, completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void ) { @@ -653,12 +653,12 @@ extension PaymentSheetLinkAccount { /// Returns a set containing the Payment Details types that the user is able to use for confirming the given `intent`. /// - Parameter intent: The Intent that the user is trying to confirm. /// - Returns: A set containing the supported Payment Details types. - func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set { + func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set> { guard let currentSession, let fundingSources = elementsSession.linkFundingSources else { return [] } - let fundingSourceDetailsTypes = Set(fundingSources.compactMap { $0.detailsType }) + let fundingSourceDetailsTypes = Set(fundingSources.map(\.detailsType)) // Take the intersection of the consumer session types and the merchant-provided Link funding sources var supportedPaymentDetailsTypes = fundingSourceDetailsTypes.intersection(currentSession.supportedPaymentDetailsTypes) @@ -675,14 +675,14 @@ extension PaymentSheetLinkAccount { var supportedPaymentMethodTypes = [STPPaymentMethodType]() for paymentDetailsType in supportedPaymentDetailsTypes(for: elementsSession) { - switch paymentDetailsType { + switch paymentDetailsType.value { case .card: supportedPaymentMethodTypes.append(.card) case .bankAccount: break // TODO(link): Fix instant debits // supportedPaymentMethodTypes.append(.instantDebits) - case .unparsable: + case nil: break } } @@ -711,14 +711,9 @@ private extension PaymentSheetLinkAccount { } -private extension LinkSettings.FundingSource { - var detailsType: ConsumerPaymentDetails.DetailsType? { - switch self { - case .card: - return .card - case .bankAccount: - return .bankAccount - } +extension ParsedEnum where E == LinkSettings.FundingSource { + var detailsType: ParsedEnum { + ParsedEnum(rawValue: rawValue) } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift index ac21281385a1..4793781cd0e8 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift @@ -20,7 +20,7 @@ final class ConsumerSession: Decodable { let unredactedPhoneNumber: String? let phoneNumberCountry: String? let verificationSessions: [VerificationSession] - let supportedPaymentDetailsTypes: Set + let supportedPaymentDetailsTypes: Set> let mobileFallbackWebviewParams: MobileFallbackWebviewParams? let currentAuthenticationLevel: AuthenticationLevel? let minimumAuthenticationLevel: AuthenticationLevel? @@ -32,7 +32,7 @@ final class ConsumerSession: Decodable { unredactedPhoneNumber: String?, phoneNumberCountry: String?, verificationSessions: [VerificationSession], - supportedPaymentDetailsTypes: Set, + supportedPaymentDetailsTypes: Set>, mobileFallbackWebviewParams: MobileFallbackWebviewParams?, currentAuthenticationLevel: AuthenticationLevel? = nil, minimumAuthenticationLevel: AuthenticationLevel? = nil @@ -70,7 +70,7 @@ final class ConsumerSession: Decodable { self.unredactedPhoneNumber = try container.decodeIfPresent(String.self, forKey: .unredactedPhoneNumber) self.phoneNumberCountry = try container.decodeIfPresent(String.self, forKey: .phoneNumberCountry) self.verificationSessions = try container.decodeIfPresent([ConsumerSession.VerificationSession].self, forKey: .verificationSessions) ?? [] - self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set.self, forKey: .supportedPaymentDetailsTypes) ?? [] + self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set>.self, forKey: .supportedPaymentDetailsTypes) ?? [] self.mobileFallbackWebviewParams = try container.decodeIfPresent(MobileFallbackWebviewParams.self, forKey: .mobileFallbackWebviewParams) self.currentAuthenticationLevel = try container.decodeIfPresent(AuthenticationLevel.self, forKey: .currentAuthenticationLevel) self.minimumAuthenticationLevel = try container.decodeIfPresent(AuthenticationLevel.self, forKey: .minimumAuthenticationLevel) @@ -372,7 +372,7 @@ extension ConsumerSession { func listPaymentDetails( with apiClient: STPAPIClient = STPAPIClient.shared, - supportedPaymentDetailsTypes: [ConsumerPaymentDetails.DetailsType], + supportedPaymentDetailsTypes: [ParsedEnum], requestSurface: LinkRequestSurface = .default, completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void ) { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/LinkPaymentMethodType.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/LinkPaymentMethodType.swift index c2098ca584d1..6e374e437401 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/LinkPaymentMethodType.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/LinkPaymentMethodType.swift @@ -7,24 +7,26 @@ import Foundation +@_spi(STP) import StripeCore + @_spi(STP) public enum LinkPaymentMethodType: String, CaseIterable { case card = "CARD" case bankAccount = "BANK_ACCOUNT" } extension Array where Element == LinkPaymentMethodType { - var detailsTypes: Set { + var detailsTypes: Set> { Set(map(\.detailsType)) } } private extension LinkPaymentMethodType { - var detailsType: ConsumerPaymentDetails.DetailsType { + var detailsType: ParsedEnum { switch self { case .card: - return .card + return ParsedEnum(.card) case .bankAccount: - return .bankAccount + return ParsedEnum(.bankAccount) } } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift index 44fd8710f436..dde7591446e9 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift @@ -127,16 +127,18 @@ extension ConsumerPaymentDetails { // MARK: - Details /// :nodoc: extension ConsumerPaymentDetails { - enum DetailsType: String, CaseIterable, SafeEnumCodable { + + // swiftlint:disable:next enum_safe_decodable + enum DetailsType: String, SafeParsedEnumCodable { case card = "CARD" case bankAccount = "BANK_ACCOUNT" - case unparsable = "" } - enum Details: SafeEnumDecodable { + // swiftlint:disable:next enum_safe_decodable + enum Details: Decodable { case card(card: Card) case bankAccount(bankAccount: BankAccount) - case unparsable + case unparsable(rawValue: String) private enum CodingKeys: String, CodingKey { case type @@ -147,26 +149,26 @@ extension ConsumerPaymentDetails { // Our JSON structure doesn't align with Swift's expected structure for enums with associated values, so we do custom decoding. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(DetailsType.self, forKey: CodingKeys.type) - switch type { + let parsedType = try container.decode(ParsedEnum.self, forKey: CodingKeys.type) + switch parsedType.value { case .card: self = .card(card: try container.decode(Card.self, forKey: CodingKeys.card)) case .bankAccount: self = .bankAccount(bankAccount: try container.decode(BankAccount.self, forKey: CodingKeys.bankAccount)) - case .unparsable: - self = .unparsable + case nil: + self = .unparsable(rawValue: parsedType.rawValue) } } } - var type: DetailsType { + var type: ParsedEnum { switch details { case .card: - return .card + return ParsedEnum(.card) case .bankAccount: - return .bankAccount - case .unparsable: - return .unparsable + return ParsedEnum(.bankAccount) + case .unparsable(let rawValue): + return ParsedEnum(rawValue: rawValue) } } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift index ab36832f1124..e674231017ed 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/STPAPIClient+Link.swift @@ -463,7 +463,7 @@ extension STPAPIClient { func listPaymentDetails( for consumerSessionClientSecret: String, - supportedPaymentDetailsTypes: [ConsumerPaymentDetails.DetailsType], + supportedPaymentDetailsTypes: [ParsedEnum], requestSurface: LinkRequestSurface = .default, completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void ) { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/PaymentMethodWithLinkDetails.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/PaymentMethodWithLinkDetails.swift index 92be24b2ff50..d403bd0b6fcb 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/PaymentMethodWithLinkDetails.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/PaymentMethodWithLinkDetails.swift @@ -7,6 +7,8 @@ import Foundation +@_spi(STP) import StripeCore + class PaymentMethodWithLinkDetails: NSObject, STPAPIResponseDecodable { let paymentMethod: STPPaymentMethod let isLinkOrigin: Bool @@ -49,8 +51,9 @@ class PaymentMethodWithLinkDetails: NSObject, STPAPIResponseDecodable { } } - if let linkDetails, linkDetails.type.isUnsupportedAsSavedPaymentMethod { - // This is a Link payment method, but we don't support the type yet. We can't render them, so hide them. + if let linkDetails, linkDetails.type.isUnparsed { + // TODO(jkelle): We'll be able to render these with the `display` metadata + // coming in https://docs.google.com/document/d/1x834BjHYro9-bDoAVaqgHm7LDPDwzpk4z_5BvxYwwtU/ return nil } @@ -62,14 +65,3 @@ class PaymentMethodWithLinkDetails: NSObject, STPAPIResponseDecodable { ) } } - -private extension ConsumerPaymentDetails.DetailsType { - var isUnsupportedAsSavedPaymentMethod: Bool { - switch self { - case .card, .bankAccount: - false - case .unparsable: - true - } - } -} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift index f53dc598e1b0..cfe3d24bc887 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker.swift @@ -75,7 +75,7 @@ final class LinkPaymentMethodPicker: UIView { return selectedPaymentMethod.map { dataSource.isPaymentMethodSupported($0) } ?? false } - var supportedPaymentMethodTypes = Set(ConsumerPaymentDetails.DetailsType.allCases) + var supportedPaymentMethodTypes = Set(ConsumerPaymentDetails.DetailsType.allCases.map(ParsedEnum.init)) var selectedPaymentMethod: ConsumerPaymentDetails? { let count = dataSource?.numberOfPaymentMethods(in: self) ?? 0 diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift index f5865681af52..b2996ddb92dd 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift @@ -203,7 +203,7 @@ extension PayWithLinkViewController { } private func createUpdateDetails(for params: LinkPaymentMethodFormElement.Params) -> UpdatePaymentDetailsParams.DetailsType? { - switch paymentMethod.type { + switch paymentMethod.type.value { case .card: return .card( expiryDate: params.expiryDate, @@ -212,7 +212,7 @@ extension PayWithLinkViewController { ) case .bankAccount: return .bankAccount(billingDetails: params.billingDetails) - case .unparsable: + case nil: // don't allow updating payment method types the SDK doesn't know about return nil } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift index 91160a495c51..edc0cb5852a9 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewModel.swift @@ -34,7 +34,7 @@ extension PayWithLinkViewController { } } - var supportedPaymentMethodTypes: Set { + var supportedPaymentMethodTypes: Set> { return context.getSupportedPaymentDetailsTypes(linkAccount: linkAccount) } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift index b974620c94a9..07079602f9f5 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift @@ -113,16 +113,20 @@ final class PayWithLinkViewController: BottomSheetViewController { /// Returns the supported payment details types for the current Link account, filtered by the supportedPaymentMethodTypes. /// Returns [.card] as fallback if no types are supported after filtering. - func getSupportedPaymentDetailsTypes(linkAccount: PaymentSheetLinkAccount) -> Set { + func getSupportedPaymentDetailsTypes(linkAccount: PaymentSheetLinkAccount) -> Set> { let allSupportedPaymentDetailsTypes = linkAccount.supportedPaymentDetailsTypes(for: elementsSession) - let supportedPaymentDetailsTypes = supportedPaymentMethodTypes?.detailsTypes ?? Set(ConsumerPaymentDetails.DetailsType.allCases) + + // TODO(jkelle): Modify this line once we want to render PMs we don't have explicit support for (#6432). + // Remove the `allCases` default for `nil` filter types. + // https://docs.google.com/document/d/1x834BjHYro9-bDoAVaqgHm7LDPDwzpk4z_5BvxYwwtU + let supportedPaymentDetailsTypes = supportedPaymentMethodTypes?.detailsTypes ?? Set(ConsumerPaymentDetails.DetailsType.allCases.map(ParsedEnum.init)) let filteredSupportedPaymentDetailsTypes = allSupportedPaymentDetailsTypes.intersection(supportedPaymentDetailsTypes) if !filteredSupportedPaymentDetailsTypes.isEmpty { return filteredSupportedPaymentDetailsTypes } else { // Card is the default payment method type when no other type is available. - return [.card] + return [ParsedEnum(.card)] } } @@ -479,7 +483,7 @@ private extension PayWithLinkViewController { if paymentDetails.isEmpty { // Check if only bank accounts are supported - if so, launch Financial Connections directly let supportedTypes = context.getSupportedPaymentDetailsTypes(linkAccount: linkAccount) - if supportedTypes == [.bankAccount] { + if supportedTypes == [ParsedEnum(.bankAccount)] { startFinancialConnections { [weak self] result in guard let self else { return } switch result { @@ -849,8 +853,8 @@ extension PayWithLinkViewController: PaymentSheetLinkAccountDelegate { } // Used to get deterministic ordering -extension Set where Element == ConsumerPaymentDetails.DetailsType { - func toSortedArray() -> [ConsumerPaymentDetails.DetailsType] { +extension Set where Element == ParsedEnum { + func toSortedArray() -> [ParsedEnum] { return self.sorted { lhs, rhs in lhs.rawValue.localizedCaseInsensitiveCompare(rhs.rawValue) == .orderedAscending } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift index 4d2abdab0bb3..af268a966746 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift @@ -26,7 +26,7 @@ extension STPElementsSession { supportsLink && (linkFundingSources?.contains(.card) ?? false) || linkPassthroughModeEnabled } - var linkFundingSources: Set? { + var linkFundingSources: Set>? { linkSettings?.fundingSources } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Utils/LinkURLGenerator.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Utils/LinkURLGenerator.swift index bfccb4c9e2f0..8222c544096b 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Utils/LinkURLGenerator.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Utils/LinkURLGenerator.swift @@ -50,7 +50,7 @@ struct LinkURLParams: Encodable { var intentMode: IntentMode var setupFutureUsage: Bool var cardBrandChoice: CardBrandChoiceInfo? - var linkFundingSources: [LinkSettings.FundingSource] + var linkFundingSources: [ParsedEnum] var clientAttributionMetadata: STPClientAttributionMetadata } @@ -133,8 +133,8 @@ class LinkURLGenerator { } // Used to get deterministic ordering for FundingSource tests -extension Set where Element == LinkSettings.FundingSource { - func toSortedArray() -> [LinkSettings.FundingSource] { +extension Set where Element == ParsedEnum { + func toSortedArray() -> [ParsedEnum] { return self.sorted { a, b in a.rawValue.localizedCaseInsensitiveCompare(b.rawValue) == .orderedAscending } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift index 921431df27d6..eb089780c923 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift @@ -964,10 +964,10 @@ private extension ConsumerPaymentDetails { func expectedPaymentMethodTypeForPassthroughMode( _ elementsSession: STPElementsSession ) -> String? { - switch type { + switch type.value { case .card: return "card" - case .unparsable: + case nil: return nil case .bankAccount: return elementsSession.useCardPaymentMethodTypeForIBP ? "card" : "bank_account" diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/LinkInlineVerificationView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/LinkInlineVerificationView.swift index 42e77b0f2da7..aedc2b32dba6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/LinkInlineVerificationView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/WalletButtonsView/LinkInlineVerificationView.swift @@ -5,6 +5,7 @@ // Created by Mat Schmid on 6/4/25. // +@_spi(STP) import StripeCore @_spi(STP) import StripeUICore import SwiftUI @@ -149,7 +150,7 @@ enum Stubs { unredactedPhoneNumber: "+17070707070", phoneNumberCountry: "US", verificationSessions: [], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil ) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentMethodsViewSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentMethodsViewSnapshotTests.swift index 44909d53c688..590538d15019 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentMethodsViewSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentMethodsViewSnapshotTests.swift @@ -1430,7 +1430,7 @@ extension PaymentSheetLinkAccount { verificationSessions: [ .init(type: .sms, state: .verified) ], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil ) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ConsumerPaymentDetailsEncodingTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ConsumerPaymentDetailsEncodingTests.swift new file mode 100644 index 000000000000..40df0c057c26 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/ConsumerPaymentDetailsEncodingTests.swift @@ -0,0 +1,30 @@ +// +// ConsumerPaymentDetailsEncodingTests.swift +// StripePaymentSheetTests +// + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet +import XCTest + +class ConsumerPaymentDetailsEncodingTests: XCTestCase { + + // Verifies that encoding a ParsedEnum for any known case + // produces the same string as the enum's rawValue. + // + // This matters because ParsedEnum always encodes via its rawValue — if the + // inner enum's rawValue ever diverges from what the API expects, this test + // will catch it. + func test_parsedEnumEncodingMatchesUnderlyingRawValue() throws { + for detailsType in ConsumerPaymentDetails.DetailsType.allCases { + let parsed = ParsedEnum(detailsType) + let encodedData = try JSONEncoder().encode(parsed) + let encodedString = try JSONDecoder().decode(String.self, from: encodedData) + XCTAssertEqual( + encodedString, + detailsType.rawValue, + "ParsedEnum encoding should match rawValue for .\(detailsType)" + ) + } + } +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkButtonSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkButtonSnapshotTests.swift index 926f1b5d8489..6c3f10ad86fa 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkButtonSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkButtonSnapshotTests.swift @@ -117,7 +117,7 @@ enum Stubs { unredactedPhoneNumber: "+17070707070", phoneNumberCountry: "US", verificationSessions: [], - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], mobileFallbackWebviewParams: nil ) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift index 7b8f77ea04cd..497334ca8f1d 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkPaymentMethodPickerSnapshotTests.swift @@ -58,7 +58,7 @@ class LinkPaymentMethodPickerSnapshotTests: STPSnapshotTestCase { let picker = LinkPaymentMethodPicker() picker.dataSource = mockDataSource - picker.supportedPaymentMethodTypes = [.card] + picker.supportedPaymentMethodTypes = [ParsedEnum(.card)] picker.layoutSubviews() picker.setExpanded(true, animated: false) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkSignUpViewControllerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkSignUpViewControllerSnapshotTests.swift index c287bf8f1964..ee8d7308668e 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkSignUpViewControllerSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkSignUpViewControllerSnapshotTests.swift @@ -61,7 +61,7 @@ extension LinkSignUpViewControllerSnapshotTests { func makeSUT(email: String?, suggestedEmail: String? = nil) throws -> LinkSignUpViewController { let (_, elementsSession) = try PayWithLinkTestHelpers.makePaymentIntentAndElementsSession() - let session = email == nil ? LinkStubs.consumerSession(supportedPaymentDetailsTypes: [.card]) : nil + let session = email == nil ? LinkStubs.consumerSession(supportedPaymentDetailsTypes: [ParsedEnum(.card)]) : nil let linkAccount = PaymentSheetLinkAccount( email: email ?? "", diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift index adb013ef44c1..a4928dc0ac2c 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift @@ -28,7 +28,7 @@ class LinkURLGeneratorTests: XCTestCase { locale: Locale.init(identifier: "en_US").toLanguageTag(), intentMode: .payment, setupFutureUsage: false, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .standard, paymentMethodSelectionFlow: .merchantSpecified) ) @@ -146,7 +146,7 @@ class LinkURLGeneratorTests: XCTestCase { locale: Locale.init(identifier: "en_US").toLanguageTag(), intentMode: .payment, setupFutureUsage: true, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic)) XCTAssertEqual(params, expectedParams) @@ -179,7 +179,7 @@ class LinkURLGeneratorTests: XCTestCase { locale: Locale.init(identifier: "en_US").toLanguageTag(), intentMode: .payment, setupFutureUsage: true, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic)) XCTAssertEqual(params, expectedParams) @@ -212,7 +212,7 @@ class LinkURLGeneratorTests: XCTestCase { locale: Locale.init(identifier: "en_US").toLanguageTag(), intentMode: .payment, setupFutureUsage: false, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic)) XCTAssertEqual(params, expectedParams) @@ -245,7 +245,7 @@ class LinkURLGeneratorTests: XCTestCase { locale: Locale.init(identifier: "en_US").toLanguageTag(), intentMode: .payment, setupFutureUsage: false, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic)) XCTAssertEqual(params, expectedParams) @@ -318,7 +318,7 @@ class LinkURLGeneratorTests: XCTestCase { intentMode: .payment, setupFutureUsage: false, cardBrandChoice: nil, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic) ) @@ -354,7 +354,7 @@ class LinkURLGeneratorTests: XCTestCase { intentMode: .payment, setupFutureUsage: false, cardBrandChoice: nil, - linkFundingSources: [.bankAccount, .card], + linkFundingSources: [ParsedEnum(.bankAccount), ParsedEnum(.card)], clientAttributionMetadata: STPClientAttributionMetadata(elementsSessionConfigId: "123", paymentIntentCreationFlow: .deferred, paymentMethodSelectionFlow: .automatic) ) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift index 1539211984de..c8ced2ff33a0 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/PayWithLinkViewController-WalletViewModelTests.swift @@ -143,7 +143,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { } func test_confirmButtonStatus_whenSelectedCardIsNotSupported() throws { - let sut = try makeSUT(supportedPaymentDetailsTypes: [.bankAccount], linkFundingSources: ["BANK_ACCOUNT"]) + let sut = try makeSUT(supportedPaymentDetailsTypes: [ParsedEnum(.bankAccount)], linkFundingSources: ["BANK_ACCOUNT"]) sut.selectedPaymentMethodIndex = LinkStubs.PaymentMethodIndices.card XCTAssertEqual( sut.confirmButtonStatus, @@ -159,7 +159,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { } func test_defaultLogic_whenDefaultCardIsNotSupportedItShouldStillBeSelected() throws { - let sut = try makeSUT(supportedPaymentDetailsTypes: [.bankAccount], linkFundingSources: ["BANK_ACCOUNT"]) + let sut = try makeSUT(supportedPaymentDetailsTypes: [ParsedEnum(.bankAccount)], linkFundingSources: ["BANK_ACCOUNT"]) XCTAssertEqual( sut.selectedPaymentMethodIndex, @@ -171,7 +171,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_defaultLogic_whenNotSupportedCardIsOnlyOption() throws { let paymentMethods = Array(LinkStubs.paymentMethods()[0..<1]) let sut = try makeSUT(paymentMethods: paymentMethods, - supportedPaymentDetailsTypes: [.bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.bankAccount)], linkFundingSources: ["BANK_ACCOUNT"]) XCTAssertEqual( sut.selectedPaymentMethodIndex, @@ -182,16 +182,16 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_supportedPaymentMethodTypes_whenFilterIsNil_usesAllCasesAtIntersection() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.bankAccount)], supportedPaymentMethodTypes: nil, linkFundingSources: ["BANK_ACCOUNT"] ) - XCTAssertEqual(sut.supportedPaymentMethodTypes, [.bankAccount]) + XCTAssertEqual(sut.supportedPaymentMethodTypes, [ParsedEnum(.bankAccount)]) } func test_cardBrandFiltering_passThroughEnabled() throws { - let sut = try makeSUT(supportedPaymentDetailsTypes: [.card], + let sut = try makeSUT(supportedPaymentDetailsTypes: [ParsedEnum(.card)], linkFundingSources: ["CARD"], cardBrandAcceptance: .disallowed(brands: [.visa]), linkPassthroughModeEnabled: true) @@ -213,7 +213,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { } func test_cardBrandFiltering_ignoredWhenPassThroughDisabled() throws { - let sut = try makeSUT(supportedPaymentDetailsTypes: [.card], + let sut = try makeSUT(supportedPaymentDetailsTypes: [ParsedEnum(.card)], linkFundingSources: ["CARD"], cardBrandAcceptance: .disallowed(brands: [.visa]), linkPassthroughModeEnabled: false) @@ -238,7 +238,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_cardFundingFiltering_debitOnly() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkFundingSources: ["CARD", "BANK_ACCOUNT"], allowedCardFundingTypes: .debit ) @@ -270,7 +270,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_cardFundingFiltering_creditOnly() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkFundingSources: ["CARD", "BANK_ACCOUNT"], allowedCardFundingTypes: .credit ) @@ -290,7 +290,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_cardFundingFiltering_prepaidOnly() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkFundingSources: ["CARD", "BANK_ACCOUNT"], allowedCardFundingTypes: .prepaid ) @@ -316,7 +316,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_cardFundingFiltering_debitAndCredit() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkFundingSources: ["CARD", "BANK_ACCOUNT"], allowedCardFundingTypes: [.debit, .credit] ) @@ -342,7 +342,7 @@ class PayWithLinkViewController_WalletViewModelTests: XCTestCase { func test_cardFundingFiltering_allFundingTypes() throws { let sut = try makeSUT( - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkFundingSources: ["CARD", "BANK_ACCOUNT"], allowedCardFundingTypes: .all ) @@ -377,7 +377,7 @@ extension PayWithLinkViewController_WalletViewModelTests { func makeSUT( paymentMethods: [ConsumerPaymentDetails] = LinkStubs.paymentMethods(), - supportedPaymentDetailsTypes: Set = [.card, .bankAccount], + supportedPaymentDetailsTypes: Set> = [ParsedEnum(.card), ParsedEnum(.bankAccount)], supportedPaymentMethodTypes: [LinkPaymentMethodType]? = nil, linkFundingSources: [String] = ["CARD"], cardBrandAcceptance: PaymentSheet.CardBrandAcceptance = .all, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift index 0980bf2efd0a..cf452813f259 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/LinkStubs.swift @@ -88,7 +88,7 @@ extension LinkStubs { ] } - static func consumerSession(supportedPaymentDetailsTypes: Set = [.card, .bankAccount]) -> ConsumerSession { + static func consumerSession(supportedPaymentDetailsTypes: Set> = [ParsedEnum(.card), ParsedEnum(.bankAccount)]) -> ConsumerSession { return ConsumerSession( clientSecret: "client_secret", emailAddress: "user@example.com", diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentMethodAvailabilityTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentMethodAvailabilityTests.swift index 986f0cba3d31..6b5c3dba94b0 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentMethodAvailabilityTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentMethodAvailabilityTests.swift @@ -174,7 +174,7 @@ extension LinkSettings { linkSupportedPaymentMethodsOnboardingEnabled: [String] = ["CARD"] ) -> LinkSettings { return .init( - fundingSources: [.card, .bankAccount], + fundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], popupWebviewOption: nil, passthroughModeEnabled: true, disableSignup: disableSignup, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift index eac27e052b70..0b7714e3f1b9 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift @@ -85,7 +85,7 @@ final class PaymentSheetAPIMockTest: APIStubbedTestCase { unredactedPhoneNumber: "(555) 555-5555", phoneNumberCountry: "US", verificationSessions: [.init(type: .sms, state: .verified)], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil ), publishableKey: "pk_xxx_for_link_account_xxx", @@ -193,7 +193,7 @@ final class PaymentSheetAPIMockTest: APIStubbedTestCase { unredactedPhoneNumber: "(555) 555-5555", phoneNumberCountry: "US", verificationSessions: [.init(type: .sms, state: .verified)], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil ), publishableKey: MockParams.publicKey, @@ -320,7 +320,7 @@ final class PaymentSheetAPIMockTest: APIStubbedTestCase { unredactedPhoneNumber: "(555) 555-5555", phoneNumberCountry: "US", verificationSessions: [.init(type: .sms, state: .verified)], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil ), publishableKey: MockParams.publicKey, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift index 8a8dbd194dd2..bfac384b1d40 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetAnalyticsHelperTest.swift @@ -529,7 +529,7 @@ final class PaymentSheetAnalyticsHelperTest: XCTestCase { analyticsClient: analyticsClient ) sut.intent = ._testValue() - sut.elementsSession = ._testValue(paymentMethodTypes: ["card"], externalPaymentMethodTypes: [], linkMode: .linkCardBrand, linkFundingSources: [.card], linkUseAttestation: true, linkSuppress2FA: true) + sut.elementsSession = ._testValue(paymentMethodTypes: ["card"], externalPaymentMethodTypes: [], linkMode: .linkCardBrand, linkFundingSources: [ParsedEnum(.card)], linkUseAttestation: true, linkSuppress2FA: true) sut.logPayment( paymentOption: paymentOption, result: result, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift index df94034e041f..846c47c8341a 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift @@ -646,7 +646,7 @@ final class PaymentSheetLPMConfirmFlowTests: STPNetworkStubbingTestCase { intentKinds: [.paymentIntent, .paymentIntentWithSetupFutureUsage, .paymentIntentWithPMOSetupFutureUsage, .setupIntent], currency: "USD", intentPaymentMethodType: .card, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], makeLinkPaymentMethod: { apiClient in let params = STPPaymentMethodParams._testCardValue(email: "paymentsheet-link-card-confirm-flows@example.com") params.card?.expMonth = 12 @@ -668,7 +668,7 @@ final class PaymentSheetLPMConfirmFlowTests: STPNetworkStubbingTestCase { intentKinds: [.paymentIntent, .paymentIntentWithSetupFutureUsage, .paymentIntentWithPMOSetupFutureUsage], currency: "USD", intentPaymentMethodType: .USBankAccount, - linkFundingSources: [.bankAccount], + linkFundingSources: [ParsedEnum(.bankAccount)], makeLinkPaymentMethod: { apiClient in try await apiClient.createPaymentMethod( with: ._testUSBankAccountValue( @@ -1440,7 +1440,7 @@ extension PaymentSheetLPMConfirmFlowTests { amount: Int? = nil, intentPaymentMethodType: STPPaymentMethodType, merchantCountry: MerchantCountry = .US, - linkFundingSources: Set, + linkFundingSources: Set>, makeLinkPaymentMethod: (STPAPIClient) async throws -> STPPaymentMethod ) async throws { // Initialize PaymentSheet at least once to set the correct payment_user_agent for this process: diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift index 7c0151ab2a39..a83c08b8106b 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLinkAccountTests.swift @@ -89,7 +89,7 @@ final class PaymentSheetLinkAccountTests: APIStubbedTestCase { sut.paymentSheetLinkAccountDelegate = PaymentSheetLinkAccountDelegateStub(expectation: refreshExp) // List the payment details. This will fail, refresh the token, then succeed. - sut.listPaymentDetails(supportedTypes: [.card, .bankAccount]) { result in + sut.listPaymentDetails(supportedTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)]) { result in switch result { case .success: listedPaymentDetailsExp.fulfill() @@ -108,7 +108,7 @@ final class PaymentSheetLinkAccountTests: APIStubbedTestCase { unredactedPhoneNumber: nil, phoneNumberCountry: nil, verificationSessions: [], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil, currentAuthenticationLevel: .oneFactorAuth, minimumAuthenticationLevel: .oneFactorAuth @@ -124,7 +124,7 @@ final class PaymentSheetLinkAccountTests: APIStubbedTestCase { unredactedPhoneNumber: nil, phoneNumberCountry: nil, verificationSessions: [], - supportedPaymentDetailsTypes: [.card], + supportedPaymentDetailsTypes: [ParsedEnum(.card)], mobileFallbackWebviewParams: nil, currentAuthenticationLevel: .notAuthenticated, minimumAuthenticationLevel: .oneFactorAuth @@ -162,7 +162,7 @@ final class PaymentSheetLinkAccountTests: APIStubbedTestCase { // List the payment details. This will fail, refresh the token, then succeed. sut.listPaymentDetails( - supportedTypes: [.card, .bankAccount], + supportedTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], shouldRetryOnAuthError: false ) { result in switch result { @@ -192,7 +192,7 @@ class PaymentSheetLinkAccountDelegateStub: PaymentSheetLinkAccountDelegate { unredactedPhoneNumber: "(555) 555-5555", phoneNumberCountry: "US", verificationSessions: [], - supportedPaymentDetailsTypes: [.card, .bankAccount], + supportedPaymentDetailsTypes: [ParsedEnum(.card), ParsedEnum(.bankAccount)], mobileFallbackWebviewParams: nil ) completion(.success(stubSession)) @@ -234,3 +234,56 @@ extension PaymentSheetLinkAccountTests { } } + +// MARK: - FundingSource.detailsType mapping tests + +final class FundingSourceDetailsTypeMappingTests: XCTestCase { + + func test_card_mapsToCardDetailsType() { + let fundingSource = ParsedEnum(LinkSettings.FundingSource.card) + XCTAssertEqual(fundingSource.detailsType, ParsedEnum(.card)) + XCTAssertEqual(fundingSource.detailsType.value, .card) + } + + func test_bankAccount_mapsToBankAccountDetailsType() { + let fundingSource = ParsedEnum(LinkSettings.FundingSource.bankAccount) + XCTAssertEqual(fundingSource.detailsType, ParsedEnum(.bankAccount)) + XCTAssertEqual(fundingSource.detailsType.value, .bankAccount) + } + + func test_unknownType_transfersRawValue() { + let fundingSource = ParsedEnum(rawValue: "PIX") + let detailsType = fundingSource.detailsType + XCTAssertNil(detailsType.value, "Unknown funding source should produce an unparsed details type") + XCTAssertEqual(detailsType.rawValue, "PIX", "Raw value should be preserved for unknown types") + } + + func test_unknownType_appearsInIntersectionWhenConsumerSessionAlsoAdvertisesIt() { + // If both the funding sources and the consumer session advertise an unknown type, + // it should survive the intersection even though neither side can parse it. + let fundingSourceDetailsTypes: Set> = [ + ParsedEnum(rawValue: "PIX"), + ParsedEnum(.card), + ] + let sessionTypes: Set> = [ + ParsedEnum(rawValue: "PIX"), + ] + let supported = fundingSourceDetailsTypes.intersection(sessionTypes) + XCTAssertEqual(supported.count, 1) + XCTAssertEqual(supported.first?.rawValue, "PIX") + XCTAssertNil(supported.first?.value) + } + + func test_unknownType_isExcludedFromIntersectionWhenSessionDoesNotAdvertiseIt() { + let fundingSourceDetailsTypes: Set> = [ + ParsedEnum(rawValue: "PIX"), + ParsedEnum(.card), + ] + let sessionTypes: Set> = [ + ParsedEnum(.card), + ] + let supported = fundingSourceDetailsTypes.intersection(sessionTypes) + XCTAssertEqual(supported.count, 1) + XCTAssertEqual(supported.first?.value, .card) + } +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetPaymentMethodTypeTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetPaymentMethodTypeTest.swift index 107a9b460382..d0f9fa59865a 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetPaymentMethodTypeTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetPaymentMethodTypeTest.swift @@ -383,7 +383,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { elementsSession: ._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ), configuration: configuration @@ -402,7 +402,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { elementsSession: ._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount] + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)] ), configuration: configuration ) @@ -418,7 +418,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ) @@ -436,7 +436,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD"] ) @@ -461,7 +461,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ) @@ -482,7 +482,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ) @@ -509,7 +509,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD"] ) @@ -540,7 +540,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card] + linkFundingSources: [ParsedEnum(.card)] ) let availability = PaymentSheet.PaymentMethodType.supportsInstantBankPayments( @@ -559,7 +559,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card] + linkFundingSources: [ParsedEnum(.card)] ) let availability = PaymentSheet.PaymentMethodType.supportsInstantBankPayments( @@ -577,7 +577,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card] + linkFundingSources: [ParsedEnum(.card)] ) let availability = PaymentSheet.PaymentMethodType.supportsInstantBankPayments( @@ -598,7 +598,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount] + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)] ) let availability = PaymentSheet.PaymentMethodType.supportsInstantBankPayments( @@ -621,7 +621,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ) @@ -639,7 +639,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD"] ) @@ -664,7 +664,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount] + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)] ) let availability = PaymentSheet.PaymentMethodType.supportsLinkCardIntegration( @@ -684,7 +684,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount], + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD", "INSTANT_DEBITS"] ) @@ -711,7 +711,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card], + linkFundingSources: [ParsedEnum(.card)], linkSupportedPaymentMethodsOnboardingEnabled: ["CARD"] ) @@ -742,7 +742,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card] + linkFundingSources: [ParsedEnum(.card)] ) let availability = PaymentSheet.PaymentMethodType.supportsLinkCardIntegration( @@ -761,7 +761,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card, .bankAccount] + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)] ) let availability = PaymentSheet.PaymentMethodType.supportsInstantBankPayments( @@ -779,7 +779,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkCardBrand, - linkFundingSources: [.card] + linkFundingSources: [ParsedEnum(.card)] ) let availability = PaymentSheet.PaymentMethodType.supportsLinkCardIntegration( @@ -800,7 +800,7 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { let elementsSession = STPElementsSession._testValue( intent: intent, linkMode: .linkPaymentMethod, - linkFundingSources: [.card, .bankAccount] + linkFundingSources: [ParsedEnum(.card), ParsedEnum(.bankAccount)] ) let availability = PaymentSheet.PaymentMethodType.supportsLinkCardIntegration( diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift index f7a74250cb01..d75b2a1edeac 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift @@ -132,7 +132,7 @@ extension STPElementsSession { cardBrandChoiceData: [String: Any]? = nil, isLinkPassthroughModeEnabled: Bool? = nil, linkMode: LinkMode? = nil, - linkFundingSources: Set = [], + linkFundingSources: Set> = [], disableLinkSignup: Bool? = nil, defaultPaymentMethod: String? = nil, paymentMethods: [[AnyHashable: Any]]? = nil, @@ -218,7 +218,7 @@ extension STPElementsSession { intent: Intent, isLinkPassthroughModeEnabled: Bool? = nil, linkMode: LinkMode? = nil, - linkFundingSources: Set = [], + linkFundingSources: Set> = [], defaultPaymentMethod: String? = nil, paymentMethods: [[AnyHashable: Any]]? = nil, allowsSetAsDefaultPM: Bool = false, diff --git a/StripePayments/StripePayments/Source/API Bindings/Models/Shared/LinkSettings.swift b/StripePayments/StripePayments/Source/API Bindings/Models/Shared/LinkSettings.swift index 674cebb70a1d..87d8480466cf 100644 --- a/StripePayments/StripePayments/Source/API Bindings/Models/Shared/LinkSettings.swift +++ b/StripePayments/StripePayments/Source/API Bindings/Models/Shared/LinkSettings.swift @@ -12,7 +12,7 @@ import Foundation /// For internal SDK use only @objc(STP_Internal_LinkSettings) @_spi(STP) public final class LinkSettings: NSObject, STPAPIResponseDecodable { - @_spi(STP) @frozen public enum FundingSource: String, Encodable { + @_spi(STP) public enum FundingSource: String, SafeParsedEnumCodable { case card = "CARD" case bankAccount = "BANK_ACCOUNT" } @@ -28,7 +28,7 @@ import Foundation case none = "NONE" } - @_spi(STP) public let fundingSources: Set + @_spi(STP) public let fundingSources: Set> @_spi(STP) public let popupWebviewOption: PopupWebviewOption? @_spi(STP) public let passthroughModeEnabled: Bool? @_spi(STP) public let disableSignup: Bool? @@ -51,7 +51,7 @@ import Foundation } @_spi(STP) public init( - fundingSources: Set, + fundingSources: Set>, popupWebviewOption: PopupWebviewOption?, passthroughModeEnabled: Bool?, disableSignup: Bool?, @@ -96,8 +96,7 @@ import Foundation return nil } - // Server may send down funding sources we haven't implemented yet, so we'll just ignore any unknown sources - let validFundingSources = Set(fundingSourcesStrings.compactMap(FundingSource.init)) + let validFundingSources = Set(fundingSourcesStrings.map { ParsedEnum(rawValue: $0) }) let webviewOption = PopupWebviewOption(rawValue: response["link_popup_webview_option"] as? String ?? "") let passthroughModeEnabled = response["link_passthrough_mode_enabled"] as? Bool ?? false