-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Parse Unknown Payment Methods in Link #6248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<E: SafeParsedEnumCodable>: Hashable { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wondering if it's ok for me to add this type in StripeCore? |
||
| /// 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<E: SafeParsedEnumCodable>(_ enumValue: E) -> Bool | ||
| where Element == ParsedEnum<E> | ||
| { | ||
| contains(ParsedEnum(enumValue)) | ||
| } | ||
|
|
||
| @_spi(STP) @discardableResult | ||
| public mutating func insert<E: SafeParsedEnumCodable>(_ enumValue: E) -> (inserted: Bool, memberAfterInsert: ParsedEnum<E>) | ||
| where Element == ParsedEnum<E> | ||
| { | ||
| insert(ParsedEnum(enumValue)) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Color> | ||
| let colors: [ParsedEnum<Color>] | ||
| } | ||
|
|
||
| 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<Color>(.red), ParsedEnum(rawValue: "RED")) | ||
| XCTAssertNotEqual(ParsedEnum<Color>(.red), ParsedEnum(.blue)) | ||
| XCTAssertNotEqual(ParsedEnum<Color>(.red), ParsedEnum(rawValue: "green")) | ||
| } | ||
|
|
||
| func test_setDeduplicationByRawValue() { | ||
| let set: Set<ParsedEnum<Color>> = [ParsedEnum(.red), ParsedEnum(rawValue: "RED")] | ||
| XCTAssertEqual(set.count, 1) | ||
| } | ||
|
|
||
| func test_unknownValuesHashableInSet() { | ||
| let set: Set<ParsedEnum<Color>> = [ParsedEnum(rawValue: "GREEN"), ParsedEnum(rawValue: "GREEN")] | ||
| XCTAssertEqual(set.count, 1) | ||
| } | ||
|
|
||
| // MARK: - Convenience operators | ||
|
|
||
| func test_comparisonWithEnumValue() { | ||
| let parsed = ParsedEnum<Color>(.red) | ||
| XCTAssertTrue(parsed == Color.red) | ||
| XCTAssertFalse(parsed == Color.blue) | ||
| } | ||
|
|
||
| func test_comparisonWithUnknownValue() { | ||
| let parsed = ParsedEnum<Color>(rawValue: "GREEN") | ||
| XCTAssertFalse(parsed == Color.red) | ||
| XCTAssertFalse(parsed == Color.blue) | ||
| } | ||
|
|
||
| // MARK: - Set convenience extension | ||
|
|
||
| func test_setContainsEnumValue() { | ||
| let set: Set<ParsedEnum<Color>> = [ParsedEnum(.red), ParsedEnum(rawValue: "GREEN")] | ||
| XCTAssertTrue(set.contains(Color.red)) | ||
| XCTAssertFalse(set.contains(Color.blue)) | ||
| } | ||
|
|
||
| func test_setInsertEnumValue() { | ||
| var set: Set<ParsedEnum<Color>> = [] | ||
| set.insert(Color.red) | ||
| XCTAssertTrue(set.contains(Color.red)) | ||
| XCTAssertEqual(set.count, 1) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -352,7 +352,7 @@ struct LinkPMDisplayDetails { | |
| } | ||
|
|
||
| func listPaymentDetails( | ||
| supportedTypes: [ConsumerPaymentDetails.DetailsType], | ||
| supportedTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>], | ||
| 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<ConsumerPaymentDetails.DetailsType>], | ||
| 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<ConsumerPaymentDetails.DetailsType> { | ||
| func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>> { | ||
| 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<ConsumerPaymentDetails.DetailsType> { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can map unknown funding sources to unknown payment detail types. Which is necessary for comparing them before calling |
||
| ParsedEnum<ConsumerPaymentDetails.DetailsType>(rawValue: rawValue) | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love feedback about the naming of this type and its usage shapes. Is it clear to the reader what it's goal is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's already a
SafeEnumDecodable. Could we extend that and add arawValuethere?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to add a separate type here since existing conformers to
SafeEnumDecodabledon't have to deal with the wrapper type right now (see this file, where we have to add the generic wrapper and parse with.value). I think this adds overhead to types that currently conform toSafeEnumDecodablethat don't need the knowledge of the unknown value.