Skip to content

Commit b64f9c3

Browse files
committed
parse unknown payment methods in native Link
1 parent 5546459 commit b64f9c3

33 files changed

Lines changed: 410 additions & 128 deletions
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// ParsedEnum.swift
3+
// StripeCore
4+
//
5+
// Created by Jeremy Kelleher on 3/24/26.
6+
// Copyright © 2026 Stripe, Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
/// A wrapper that pairs a parsed enum value with the raw API string.
12+
///
13+
/// Known values: `value` is non-nil, `rawValue` matches the enum's raw value.
14+
/// Unknown values: `value` is nil, `rawValue` contains the unrecognized API string.
15+
/// :nodoc:
16+
@_spi(STP) public struct ParsedEnum<E: SafeParsedEnumCodable>: Hashable {
17+
/// The parsed enum value, or nil if the API string was unrecognized.
18+
public let value: E?
19+
/// The raw API string, always preserved.
20+
public let rawValue: String
21+
22+
/// Initialize from a known enum value.
23+
public init(_ value: E) {
24+
self.value = value
25+
self.rawValue = value.rawValue
26+
}
27+
28+
/// Initialize from a raw API string, attempting to parse the enum.
29+
public init(rawValue: String) {
30+
self.rawValue = rawValue
31+
self.value = E(rawValue: rawValue)
32+
}
33+
34+
/// True if the value was not recognized by the SDK.
35+
public var isUnparsed: Bool { value == nil }
36+
37+
// MARK: - Hashable / Equatable
38+
39+
public func hash(into hasher: inout Hasher) {
40+
hasher.combine(rawValue)
41+
}
42+
43+
public static func == (lhs: Self, rhs: Self) -> Bool {
44+
lhs.rawValue == rhs.rawValue
45+
}
46+
}
47+
48+
// MARK: - Codable
49+
50+
extension ParsedEnum: Decodable {
51+
public init(from decoder: Decoder) throws {
52+
let container = try decoder.singleValueContainer()
53+
let raw = try container.decode(String.self)
54+
self.init(rawValue: raw)
55+
}
56+
}
57+
58+
extension ParsedEnum: Encodable {
59+
public func encode(to encoder: Encoder) throws {
60+
var container = encoder.singleValueContainer()
61+
try container.encode(rawValue)
62+
}
63+
}
64+
65+
extension ParsedEnum {
66+
public static func == (lhs: Self, rhs: E) -> Bool {
67+
lhs.value == rhs
68+
}
69+
}
70+
71+
extension Set {
72+
@_spi(STP) public func contains<E: SafeParsedEnumCodable>(_ enumValue: E) -> Bool
73+
where Element == ParsedEnum<E>
74+
{
75+
contains(ParsedEnum(enumValue))
76+
}
77+
78+
@_spi(STP) @discardableResult
79+
public mutating func insert<E: SafeParsedEnumCodable>(_ enumValue: E) -> (inserted: Bool, memberAfterInsert: ParsedEnum<E>)
80+
where Element == ParsedEnum<E>
81+
{
82+
insert(ParsedEnum(enumValue))
83+
}
84+
}

StripeCore/StripeCore/Source/Coder/StripeCodable.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ public protocol SafeEnumDecodable: Decodable {
5050
/// :nodoc:
5151
public protocol SafeEnumCodable: Encodable, SafeEnumDecodable {}
5252

53+
/// A protocol for enums whose raw API string values should be preserved
54+
/// even when the value is unknown to the SDK.
55+
/// Unlike SafeEnumCodable, the raw string is never lost — use with `ParsedEnum`.
56+
/// :nodoc:
57+
@_spi(STP) public protocol SafeParsedEnumCodable: RawRepresentable, CaseIterable, Hashable
58+
where RawValue == String {}
59+
5360
extension UnknownFieldsDecodable {
5461
/// A dictionary containing all response fields from the original JSON,
5562
/// including unknown fields.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//
2+
// ParsedEnumTests.swift
3+
// StripeCoreTests
4+
//
5+
6+
import Foundation
7+
@_spi(STP)@testable import StripeCore
8+
import XCTest
9+
10+
private enum Color: String, SafeParsedEnumCodable {
11+
case red = "RED"
12+
case blue = "BLUE"
13+
}
14+
15+
private struct Container: Codable {
16+
let color: ParsedEnum<Color>
17+
let colors: [ParsedEnum<Color>]
18+
}
19+
20+
class ParsedEnumTests: XCTestCase {
21+
22+
// MARK: - Decoding
23+
24+
func test_decodesKnownValue() throws {
25+
let json = #"{"color":"RED","colors":["RED","BLUE"]}"#
26+
let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!)
27+
XCTAssertEqual(result.color.value, .red)
28+
XCTAssertEqual(result.color.rawValue, "RED")
29+
}
30+
31+
func test_decodesUnknownValue() throws {
32+
let json = #"{"color":"GREEN","colors":[]}"#
33+
let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!)
34+
XCTAssertNil(result.color.value)
35+
XCTAssertEqual(result.color.rawValue, "GREEN")
36+
XCTAssertTrue(result.color.isUnparsed)
37+
}
38+
39+
func test_decodesArrayOfMixed() throws {
40+
let json = #"{"color":"RED","colors":["RED","GREEN","BLUE"]}"#
41+
let result = try JSONDecoder().decode(Container.self, from: json.data(using: .utf8)!)
42+
XCTAssertEqual(result.colors[0].value, .red)
43+
XCTAssertNil(result.colors[1].value)
44+
XCTAssertEqual(result.colors[1].rawValue, "GREEN")
45+
XCTAssertEqual(result.colors[2].value, .blue)
46+
}
47+
48+
// MARK: - Encoding
49+
50+
func test_encodesKnownValue() throws {
51+
let container = Container(color: ParsedEnum(.red), colors: [])
52+
let data = try JSONEncoder().encode(container)
53+
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
54+
XCTAssertEqual(json["color"] as? String, "RED")
55+
}
56+
57+
func test_encodesUnknownValuePreservingRawString() throws {
58+
let container = Container(color: ParsedEnum(rawValue: "GREEN"), colors: [])
59+
let data = try JSONEncoder().encode(container)
60+
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
61+
XCTAssertEqual(json["color"] as? String, "GREEN")
62+
}
63+
64+
// MARK: - Hashable / Equatable
65+
66+
func test_equalityBasedOnRawValue() {
67+
XCTAssertEqual(ParsedEnum<Color>(.red), ParsedEnum(rawValue: "RED"))
68+
XCTAssertNotEqual(ParsedEnum<Color>(.red), ParsedEnum(.blue))
69+
XCTAssertNotEqual(ParsedEnum<Color>(.red), ParsedEnum(rawValue: "green"))
70+
}
71+
72+
func test_setDeduplicationByRawValue() {
73+
let set: Set<ParsedEnum<Color>> = [ParsedEnum(.red), ParsedEnum(rawValue: "RED")]
74+
XCTAssertEqual(set.count, 1)
75+
}
76+
77+
func test_unknownValuesHashableInSet() {
78+
let set: Set<ParsedEnum<Color>> = [ParsedEnum(rawValue: "GREEN"), ParsedEnum(rawValue: "GREEN")]
79+
XCTAssertEqual(set.count, 1)
80+
}
81+
82+
// MARK: - Convenience operators
83+
84+
func test_comparisonWithEnumValue() {
85+
let parsed = ParsedEnum<Color>(.red)
86+
XCTAssertTrue(parsed == Color.red)
87+
XCTAssertFalse(parsed == Color.blue)
88+
}
89+
90+
func test_comparisonWithUnknownValue() {
91+
let parsed = ParsedEnum<Color>(rawValue: "GREEN")
92+
XCTAssertFalse(parsed == Color.red)
93+
XCTAssertFalse(parsed == Color.blue)
94+
}
95+
96+
// MARK: - Set convenience extension
97+
98+
func test_setContainsEnumValue() {
99+
let set: Set<ParsedEnum<Color>> = [ParsedEnum(.red), ParsedEnum(rawValue: "GREEN")]
100+
XCTAssertTrue(set.contains(Color.red))
101+
XCTAssertFalse(set.contains(Color.blue))
102+
}
103+
104+
func test_setInsertEnumValue() {
105+
var set: Set<ParsedEnum<Color>> = []
106+
set.insert(Color.red)
107+
XCTAssertTrue(set.contains(Color.red))
108+
XCTAssertEqual(set.count, 1)
109+
}
110+
}

StripePaymentSheet/StripePaymentSheet/Source/Helpers/PaymentSheetLinkAccount.swift

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ struct LinkPMDisplayDetails {
352352
}
353353

354354
func listPaymentDetails(
355-
supportedTypes: [ConsumerPaymentDetails.DetailsType],
355+
supportedTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>],
356356
shouldRetryOnAuthError: Bool = true
357357
) async throws -> [ConsumerPaymentDetails] {
358358
return try await withCheckedThrowingContinuation { continuation in
@@ -371,7 +371,7 @@ struct LinkPMDisplayDetails {
371371
}
372372

373373
func listPaymentDetails(
374-
supportedTypes: [ConsumerPaymentDetails.DetailsType],
374+
supportedTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>],
375375
shouldRetryOnAuthError: Bool = true,
376376
completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void
377377
) {
@@ -653,12 +653,12 @@ extension PaymentSheetLinkAccount {
653653
/// Returns a set containing the Payment Details types that the user is able to use for confirming the given `intent`.
654654
/// - Parameter intent: The Intent that the user is trying to confirm.
655655
/// - Returns: A set containing the supported Payment Details types.
656-
func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set<ConsumerPaymentDetails.DetailsType> {
656+
func supportedPaymentDetailsTypes(for elementsSession: STPElementsSession) -> Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>> {
657657
guard let currentSession, let fundingSources = elementsSession.linkFundingSources else {
658658
return []
659659
}
660660

661-
let fundingSourceDetailsTypes = Set(fundingSources.compactMap { $0.detailsType })
661+
let fundingSourceDetailsTypes = Set(fundingSources.map(\.detailsType))
662662

663663
// Take the intersection of the consumer session types and the merchant-provided Link funding sources
664664
var supportedPaymentDetailsTypes = fundingSourceDetailsTypes.intersection(currentSession.supportedPaymentDetailsTypes)
@@ -675,14 +675,14 @@ extension PaymentSheetLinkAccount {
675675
var supportedPaymentMethodTypes = [STPPaymentMethodType]()
676676

677677
for paymentDetailsType in supportedPaymentDetailsTypes(for: elementsSession) {
678-
switch paymentDetailsType {
678+
switch paymentDetailsType.value {
679679
case .card:
680680
supportedPaymentMethodTypes.append(.card)
681681
case .bankAccount:
682682
break
683683
// TODO(link): Fix instant debits
684684
// supportedPaymentMethodTypes.append(.instantDebits)
685-
case .unparsable:
685+
case nil:
686686
break
687687
}
688688
}
@@ -711,14 +711,9 @@ private extension PaymentSheetLinkAccount {
711711

712712
}
713713

714-
private extension LinkSettings.FundingSource {
715-
var detailsType: ConsumerPaymentDetails.DetailsType? {
716-
switch self {
717-
case .card:
718-
return .card
719-
case .bankAccount:
720-
return .bankAccount
721-
}
714+
extension ParsedEnum where E == LinkSettings.FundingSource {
715+
var detailsType: ParsedEnum<ConsumerPaymentDetails.DetailsType> {
716+
ParsedEnum<ConsumerPaymentDetails.DetailsType>(rawValue: rawValue)
722717
}
723718
}
724719

StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/ConsumerSession.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ final class ConsumerSession: Decodable {
2020
let unredactedPhoneNumber: String?
2121
let phoneNumberCountry: String?
2222
let verificationSessions: [VerificationSession]
23-
let supportedPaymentDetailsTypes: Set<ConsumerPaymentDetails.DetailsType>
23+
let supportedPaymentDetailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>
2424
let mobileFallbackWebviewParams: MobileFallbackWebviewParams?
2525
let currentAuthenticationLevel: AuthenticationLevel?
2626
let minimumAuthenticationLevel: AuthenticationLevel?
@@ -32,7 +32,7 @@ final class ConsumerSession: Decodable {
3232
unredactedPhoneNumber: String?,
3333
phoneNumberCountry: String?,
3434
verificationSessions: [VerificationSession],
35-
supportedPaymentDetailsTypes: Set<ConsumerPaymentDetails.DetailsType>,
35+
supportedPaymentDetailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>,
3636
mobileFallbackWebviewParams: MobileFallbackWebviewParams?,
3737
currentAuthenticationLevel: AuthenticationLevel? = nil,
3838
minimumAuthenticationLevel: AuthenticationLevel? = nil
@@ -70,7 +70,7 @@ final class ConsumerSession: Decodable {
7070
self.unredactedPhoneNumber = try container.decodeIfPresent(String.self, forKey: .unredactedPhoneNumber)
7171
self.phoneNumberCountry = try container.decodeIfPresent(String.self, forKey: .phoneNumberCountry)
7272
self.verificationSessions = try container.decodeIfPresent([ConsumerSession.VerificationSession].self, forKey: .verificationSessions) ?? []
73-
self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set<ConsumerPaymentDetails.DetailsType>.self, forKey: .supportedPaymentDetailsTypes) ?? []
73+
self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>.self, forKey: .supportedPaymentDetailsTypes) ?? []
7474
self.mobileFallbackWebviewParams = try container.decodeIfPresent(MobileFallbackWebviewParams.self, forKey: .mobileFallbackWebviewParams)
7575
self.currentAuthenticationLevel = try container.decodeIfPresent(AuthenticationLevel.self, forKey: .currentAuthenticationLevel)
7676
self.minimumAuthenticationLevel = try container.decodeIfPresent(AuthenticationLevel.self, forKey: .minimumAuthenticationLevel)
@@ -372,7 +372,7 @@ extension ConsumerSession {
372372

373373
func listPaymentDetails(
374374
with apiClient: STPAPIClient = STPAPIClient.shared,
375-
supportedPaymentDetailsTypes: [ConsumerPaymentDetails.DetailsType],
375+
supportedPaymentDetailsTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>],
376376
requestSurface: LinkRequestSurface = .default,
377377
completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void
378378
) {

StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/LinkPaymentMethodType.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,26 @@
77

88
import Foundation
99

10+
@_spi(STP) import StripeCore
11+
1012
@_spi(STP) public enum LinkPaymentMethodType: String, CaseIterable {
1113
case card = "CARD"
1214
case bankAccount = "BANK_ACCOUNT"
1315
}
1416

1517
extension Array where Element == LinkPaymentMethodType {
16-
var detailsTypes: Set<ConsumerPaymentDetails.DetailsType> {
18+
var detailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>> {
1719
Set(map(\.detailsType))
1820
}
1921
}
2022

2123
private extension LinkPaymentMethodType {
22-
var detailsType: ConsumerPaymentDetails.DetailsType {
24+
var detailsType: ParsedEnum<ConsumerPaymentDetails.DetailsType> {
2325
switch self {
2426
case .card:
25-
return .card
27+
return ParsedEnum(.card)
2628
case .bankAccount:
27-
return .bankAccount
29+
return ParsedEnum(.bankAccount)
2830
}
2931
}
3032
}

0 commit comments

Comments
 (0)