Skip to content
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

Send and decode CPMs from elements/sessions #4637

Merged
merged 9 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ class PlaygroundController: ObservableObject {
switch settings.customPaymentMethods {
case .on:
// Obtained from https://dashboard.stripe.com/settings/custom_payment_methods
let customPaymentMethodType = PaymentSheet.CustomPaymentMethodConfiguration.CustomPaymentMethodType(id: "cpmt_1QpId5Lu5o3P18ZpLwSqMXws",
let customPaymentMethodType = PaymentSheet.CustomPaymentMethodConfiguration.CustomPaymentMethodType(id: "cpmt_1QpIMNLu5o3P18Zpwln1Sm6I",
subcopy: "Pay with BufoPay")
return .init(customPaymentMethodTypes: [customPaymentMethodType], customPaymentMethodConfirmHandler: handleCustomPaymentMethod(_:_:))
case .off:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ import Foundation
case paymentSheetElementsSessionLoadFailed = "mc_elements_session_load_failed"
case paymentSheetElementsSessionCustomerDeserializeFailed = "mc_elements_session_customer_deserialize_failed"
case paymentSheetElementsSessionEPMLoadFailed = "mc_elements_session_epms_load_failed"
case paymentSheetElementsSessionCPMLoadFailed = "mc_elements_session_cpms_load_failed"

// MARK: - PaymentSheet card brand choice
case paymentSheetDisplayCardBrandDropdownIndicator = "mc_display_cbc_dropdown"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
61D842892CADE4B9009D2D51 /* PaymentElementConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D842882CADE4B9009D2D51 /* PaymentElementConfiguration.swift */; };
61D842912CB06047009D2D51 /* FormMandateProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D842902CB06047009D2D51 /* FormMandateProviderTests.swift */; };
61D8688E2C06553E001FAD84 /* RightAccessoryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D8688D2C06553E001FAD84 /* RightAccessoryButton.swift */; };
61D946062D7901B400BFCD0C /* CustomPaymentMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D946052D7901B400BFCD0C /* CustomPaymentMethod.swift */; };
61ED657C2D41AE1A00DD5E92 /* PaymentSheet+PaymentMethodAvailabilityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61ED657B2D41AE1A00DD5E92 /* PaymentSheet+PaymentMethodAvailabilityTest.swift */; };
61FB6BCD2C8901B200F8E074 /* EmbeddedPaymentMethodsViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FB6BCC2C8901B200F8E074 /* EmbeddedPaymentMethodsViewSnapshotTests.swift */; };
623C2D9F87929D6DA9C09E23 /* STPCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39B31D0B890A4F8E4819B15 /* STPCameraView.swift */; };
Expand Down Expand Up @@ -623,6 +624,7 @@
61D842882CADE4B9009D2D51 /* PaymentElementConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentElementConfiguration.swift; sourceTree = "<group>"; };
61D842902CB06047009D2D51 /* FormMandateProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormMandateProviderTests.swift; sourceTree = "<group>"; };
61D8688D2C06553E001FAD84 /* RightAccessoryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightAccessoryButton.swift; sourceTree = "<group>"; };
61D946052D7901B400BFCD0C /* CustomPaymentMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPaymentMethod.swift; sourceTree = "<group>"; };
61ED657B2D41AE1A00DD5E92 /* PaymentSheet+PaymentMethodAvailabilityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentSheet+PaymentMethodAvailabilityTest.swift"; sourceTree = "<group>"; };
61FB6BCC2C8901B200F8E074 /* EmbeddedPaymentMethodsViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPaymentMethodsViewSnapshotTests.swift; sourceTree = "<group>"; };
62CE362B80042827F47ABC3F /* AffirmCopyLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffirmCopyLabel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -925,6 +927,7 @@
isa = PBXGroup;
children = (
2BFA5FA03C7093654EC6926F /* ExternalPaymentMethod.swift */,
61D946052D7901B400BFCD0C /* CustomPaymentMethod.swift */,
CC3498CF4AEAA8F169616CDF /* STPCardBrandChoice.swift */,
5CA2D5E6CAA2990D88F64A4D /* STPElementsSession.swift */,
6B31B9B72B9019730064E210 /* ElementsCustomer.swift */,
Expand Down Expand Up @@ -2144,6 +2147,7 @@
AA3A96D74B1659CB5725E95F /* CardExpiryDate.swift in Sources */,
64DE5688E4FBE92E1F49810C /* ExternalPaymentMethod.swift in Sources */,
229A4A578609A3711F02682E /* STPCardBrandChoice.swift in Sources */,
61D946062D7901B400BFCD0C /* CustomPaymentMethod.swift in Sources */,
3EDFACA133567159875143C5 /* STPElementsSession.swift in Sources */,
1BFC617EED154D32BFCADAE7 /* SeparatorLabel.swift in Sources */,
01D46644D87983FC4387B92C /* InstantDebitsOnlyFinancialConnectionsAuthManager.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ extension STPAPIClient {

func makeElementsSessionsParams(mode: PaymentSheet.InitializationMode,
epmConfiguration: PaymentSheet.ExternalPaymentMethodConfiguration?,
cpmConfiguration: PaymentSheet.CustomPaymentMethodConfiguration?,
clientDefaultPaymentMethod: String?,
customerAccessProvider: PaymentSheet.CustomerAccessProvider?) -> [String: Any] {
var parameters: [String: Any] = [
"locale": Locale.current.toLanguageTag(),
"external_payment_methods": epmConfiguration?.externalPaymentMethods.compactMap { $0.lowercased() } ?? [],
"custom_payment_methods": cpmConfiguration?.customPaymentMethodTypes.compactMap { $0.id } ?? [],
]
if let appId = Bundle.main.bundleIdentifier {
parameters["mobile_app_id"] = appId
Expand Down Expand Up @@ -77,6 +79,7 @@ extension STPAPIClient {
endpoint: APIEndpointElementsSessions,
parameters: makeElementsSessionsParams(mode: .paymentIntentClientSecret(paymentIntentClientSecret),
epmConfiguration: configuration.externalPaymentMethodConfiguration,
cpmConfiguration: configuration.customPaymentMethodConfiguration,
clientDefaultPaymentMethod: clientDefaultPaymentMethod,
customerAccessProvider: configuration.customer?.customerAccessProvider)
)
Expand All @@ -100,6 +103,7 @@ extension STPAPIClient {
endpoint: APIEndpointElementsSessions,
parameters: makeElementsSessionsParams(mode: .setupIntentClientSecret(setupIntentClientSecret),
epmConfiguration: configuration.externalPaymentMethodConfiguration,
cpmConfiguration: configuration.customPaymentMethodConfiguration,
clientDefaultPaymentMethod: clientDefaultPaymentMethod,
customerAccessProvider: configuration.customer?.customerAccessProvider)
)
Expand All @@ -120,6 +124,7 @@ extension STPAPIClient {
) async throws -> STPElementsSession {
let parameters = makeElementsSessionsParams(mode: .deferredIntent(intentConfig),
epmConfiguration: configuration.externalPaymentMethodConfiguration,
cpmConfiguration: configuration.customPaymentMethodConfiguration,
clientDefaultPaymentMethod: clientDefaultPaymentMethod,
customerAccessProvider: configuration.customer?.customerAccessProvider)
return try await APIRequest<STPElementsSession>.getWith(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// CustomPaymentMethod.swift
// StripePaymentSheet
//
// Created by Nick Porter on 3/5/25.
//

import Foundation
@_spi(STP) import StripeCore
@_spi(STP) import StripePayments

/// ViewModel-like information for displaying custom payment methods (CPMs), delivered in the `v1/elements/sessions` response.
struct CustomPaymentMethod: Decodable {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that all these properties (besides type) are intentionally optional, see an example response here for why: https://github.com/stripe/stripe-ios/pull/4637/files#diff-c71d9097e968f7c5fd01a4cb9425c80f53998412b3026808eca160f1ca08b35fR139

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Just a comment, no changes required, but something to think about for the future:)

I verified that you have modeled this correctly based on there response object, but feels like we need to add a layer of abstraction or something -- e.g. if error is nil, then i'd expect displayName, logoUrlandisPreset` not to be nil -- and if error has a value, then i'd expect those other values to be nil.

/// The display name of this custom payment method as defined in the Stripe dashboard
let displayName: String?

/// The type (id) of the custom payment method. e.g. `"cpmt_..."`
/// These match the ids specified by the merchant in `CustomPaymentMethodConfiguration`.
let type: String

/// URL of a 48x pixel tall, variable width PNG representing the payment method.
let logoUrl: URL?

/// If true, this custom payment method was created using a preset in the Stripe dashboard
let isPreset: Bool?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean for isPreset to be nil vs false?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I touched on it in this comment: #4637 (comment)

For more of an explanation though, this is optional based on the behavior of the backend. There are instances where the backend may not return us "is_preset" in the response so our model client-side needs to handle that. The case where isPreset is nil, is the case where there is no value specified by the backend. The goal of CustomPaymentMethod is to match the backend definition/behavior.


/// If there was an error fetching this custom payment method this will be populated with the error
let error: String?

/// Helper method to decode the `v1/elements/sessions` response's `custom_payment_methods_data` hash.
/// - Parameter response: The value of the `custom_payment_methods_data` key in the `v1/elements/sessions` response.
public static func decoded(fromAPIResponse response: [[AnyHashable: Any]]?) -> [CustomPaymentMethod]? {
guard let response else {
return nil
}
do {
return try response.map { dict in
let data = try JSONSerialization.data(withJSONObject: dict)
return try StripeJSONDecoder().decode(CustomPaymentMethod.self, from: data)
}
} catch {
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import Foundation
/// An ordered list of external payment methods to display
let externalPaymentMethods: [ExternalPaymentMethod]

/// An ordered list of custom payment methods to display
let customPaymentMethods: [CustomPaymentMethod]

let customer: ElementsCustomer?

/// A flag that indicates that this instance was created as a best-effort
Expand All @@ -66,6 +69,7 @@ import Foundation
cardBrandChoice: STPCardBrandChoice?,
isApplePayEnabled: Bool,
externalPaymentMethods: [ExternalPaymentMethod],
customPaymentMethods: [CustomPaymentMethod],
customer: ElementsCustomer?,
isBackupInstance: Bool = false
) {
Expand All @@ -81,6 +85,7 @@ import Foundation
self.cardBrandChoice = cardBrandChoice
self.isApplePayEnabled = isApplePayEnabled
self.externalPaymentMethods = externalPaymentMethods
self.customPaymentMethods = customPaymentMethods
self.customer = customer
self.isBackupInstance = isBackupInstance
super.init()
Expand Down Expand Up @@ -116,6 +121,7 @@ import Foundation
cardBrandChoice: STPCardBrandChoice.decodedObject(fromAPIResponse: [:]),
isApplePayEnabled: true,
externalPaymentMethods: [],
customPaymentMethods: [],
customer: nil,
isBackupInstance: true
)
Expand Down Expand Up @@ -169,6 +175,23 @@ extension STPElementsSession: STPAPIResponseDecodable {
return epms
}()

let customPaymentMethods: [CustomPaymentMethod] = {
let customPaymentMethodDataKey = "custom_payment_method_data"
guard response[customPaymentMethodDataKey] != nil, !(response[customPaymentMethodDataKey] is NSNull) else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to check both response[customPaymentMethodDataKey] != nil and !(response[customPaymentMethodDataKey] is NSNull)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look higher up in this function you will see we do this for external payment methods and customer. The reason is b/c:

  1. nil check: This checks if the key doesn't exist in the response dictionary at all.
  2. NSNull check: This checks if the key exists but its value is explicitly set to NSNull (null)

Here is an example:

Case 1: Key doesn't exist (nil check)

{
  "other_data": "some value",
  "payment_methods": ["card", "paypal"]
  // "custom_payment_method_data" key is completely absent
}

Case 2:

{
  "other_data": "some value",
  "payment_methods": ["card", "paypal"],
  "custom_payment_method_data": null
}

We need to be able to handle both scenarios just to be safe.

return []
}
guard
let cpmsJSON = response[customPaymentMethodDataKey] as? [[AnyHashable: Any]],
let cpms = CustomPaymentMethod.decoded(fromAPIResponse: cpmsJSON)
else {
// We don't want to fail the entire v1/elements/sessions request if we fail to parse custom_payment_methods_data
// Instead, fall back to an empty array and log an error.
STPAnalyticsClient.sharedClient.logPaymentSheetEvent(event: .paymentSheetElementsSessionCPMLoadFailed)
return []
}
return cpms
}()

return self.init(
allResponseFields: response,
sessionID: sessionID,
Expand All @@ -184,6 +207,7 @@ extension STPElementsSession: STPAPIResponseDecodable {
cardBrandChoice: cardBrandChoice,
isApplePayEnabled: isApplePayEnabled,
externalPaymentMethods: externalPaymentMethods,
customPaymentMethods: customPaymentMethods,
customer: customer
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ protocol PaymentElementConfiguration: PaymentMethodRequirementProvider {
var billingDetailsCollectionConfiguration: PaymentSheet.BillingDetailsCollectionConfiguration { get set }
var removeSavedPaymentMethodMessage: String? { get set }
var externalPaymentMethodConfiguration: PaymentSheet.ExternalPaymentMethodConfiguration? { get set }
var customPaymentMethodConfiguration: PaymentSheet.CustomPaymentMethodConfiguration? { get set }
var paymentMethodOrder: [String]? { get set }
var allowsRemovalOfLastSavedPaymentMethod: Bool { get set }
var cardBrandAcceptance: PaymentSheet.CardBrandAcceptance { get set }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ extension PaymentSheet {
/// Defines a custom payment method type that can be displayed in PaymentSheet
public struct CustomPaymentMethodType {

/// The unique identifier for this custom payment method type in the format of "cmpt_..."
/// The unique identifier for this custom payment method type in the format of "cpmt_..."
/// Obtained from the Stripe Dashboard at https://dashboard.stripe.com/settings/custom_payment_methods
public let id: String

Expand All @@ -534,7 +534,7 @@ extension PaymentSheet {

/// Initializes an `CustomPaymentMethodType`
/// - Parameters:
/// - id: The unique identifier for this custom payment method type in the format of "cmpt_..."
/// - id: The unique identifier for this custom payment method type in the format of "cpmt_..."
/// - subcopy: Optional subcopy text to be displayed below the custom payment method's display name.
public init(id: String, subcopy: String? = nil) {
self.id = id
Expand Down
Loading
Loading