Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions StripeCore/StripeCore/Source/Coder/ParsedEnum.swift
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 {
Copy link
Copy Markdown
Contributor Author

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?

Copy link
Copy Markdown
Collaborator

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 a rawValue there?

Copy link
Copy Markdown
Contributor Author

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 SafeEnumDecodable don'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 to SafeEnumDecodable that don't need the knowledge of the unknown value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?
cc @porter-stripe since you got the random assignment of codeowners, but anyone feel free to add input!

/// 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))
}
}
7 changes: 7 additions & 0 deletions StripeCore/StripeCore/Source/Coder/StripeCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 110 additions & 0 deletions StripeCore/StripeCoreTests/API Bindings/ParsedEnumTests.swift
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
Expand Up @@ -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
Expand All @@ -371,7 +371,7 @@ struct LinkPMDisplayDetails {
}

func listPaymentDetails(
supportedTypes: [ConsumerPaymentDetails.DetailsType],
supportedTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>],
shouldRetryOnAuthError: Bool = true,
completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void
) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 payment_details/list

ParsedEnum<ConsumerPaymentDetails.DetailsType>(rawValue: rawValue)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class ConsumerSession: Decodable {
let unredactedPhoneNumber: String?
let phoneNumberCountry: String?
let verificationSessions: [VerificationSession]
let supportedPaymentDetailsTypes: Set<ConsumerPaymentDetails.DetailsType>
let supportedPaymentDetailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>
let mobileFallbackWebviewParams: MobileFallbackWebviewParams?
let currentAuthenticationLevel: AuthenticationLevel?
let minimumAuthenticationLevel: AuthenticationLevel?
Expand All @@ -32,7 +32,7 @@ final class ConsumerSession: Decodable {
unredactedPhoneNumber: String?,
phoneNumberCountry: String?,
verificationSessions: [VerificationSession],
supportedPaymentDetailsTypes: Set<ConsumerPaymentDetails.DetailsType>,
supportedPaymentDetailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>,
mobileFallbackWebviewParams: MobileFallbackWebviewParams?,
currentAuthenticationLevel: AuthenticationLevel? = nil,
minimumAuthenticationLevel: AuthenticationLevel? = nil
Expand Down Expand Up @@ -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<ConsumerPaymentDetails.DetailsType>.self, forKey: .supportedPaymentDetailsTypes) ?? []
self.supportedPaymentDetailsTypes = try container.decodeIfPresent(Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>>.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)
Expand Down Expand Up @@ -372,7 +372,7 @@ extension ConsumerSession {

func listPaymentDetails(
with apiClient: STPAPIClient = STPAPIClient.shared,
supportedPaymentDetailsTypes: [ConsumerPaymentDetails.DetailsType],
supportedPaymentDetailsTypes: [ParsedEnum<ConsumerPaymentDetails.DetailsType>],
requestSurface: LinkRequestSurface = .default,
completion: @escaping (Result<[ConsumerPaymentDetails], Error>) -> Void
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConsumerPaymentDetails.DetailsType> {
var detailsTypes: Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>> {
Set(map(\.detailsType))
}
}

private extension LinkPaymentMethodType {
var detailsType: ConsumerPaymentDetails.DetailsType {
var detailsType: ParsedEnum<ConsumerPaymentDetails.DetailsType> {
switch self {
case .card:
return .card
return ParsedEnum(.card)
case .bankAccount:
return .bankAccount
return ParsedEnum(.bankAccount)
}
}
}
Loading
Loading