From 161e196319a05cd5d18f2084e646bd51e903f956 Mon Sep 17 00:00:00 2001 From: Hugo EXTRAT Date: Thu, 9 Apr 2026 15:50:54 +0200 Subject: [PATCH 1/5] feat: expose stripe link payment --- .../PaymentMethodCreateParamsFactory.kt | 9 +++++++++ ios/PaymentMethodFactory.swift | 13 +++++++++++++ src/types/PaymentIntent.ts | 12 +++++++++++- src/types/PaymentMethod.ts | 11 ++++++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt b/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt index 9aa8f6c777..126b11e177 100644 --- a/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt +++ b/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt @@ -55,6 +55,7 @@ class PaymentMethodCreateParamsFactory( PaymentMethod.Type.Affirm -> createAffirmParams() PaymentMethod.Type.CashAppPay -> createCashAppParams() PaymentMethod.Type.RevolutPay -> createRevolutPayParams() + PaymentMethod.Type.Link -> createLinkParams() else -> { throw Exception("This paymentMethodType is not supported yet") } @@ -236,6 +237,13 @@ class PaymentMethodCreateParamsFactory( metadata = metadataParams, ) + @Throws(PaymentMethodCreateParamsException::class) + private fun createLinkParams(): PaymentMethodCreateParams = + PaymentMethodCreateParams.createLink( + billingDetails = billingDetailsParams, + metadata = metadataParams, + ) + @Throws(PaymentMethodCreateParamsException::class) fun createParams( clientSecret: String, @@ -249,6 +257,7 @@ class PaymentMethodCreateParamsFactory( createUSBankAccountStripeIntentParams(clientSecret, isPaymentIntent) PaymentMethod.Type.Affirm -> createAffirmStripeIntentParams(clientSecret, isPaymentIntent) + PaymentMethod.Type.Link, PaymentMethod.Type.Ideal, PaymentMethod.Type.Alipay, PaymentMethod.Type.Alma, diff --git a/ios/PaymentMethodFactory.swift b/ios/PaymentMethodFactory.swift index 1d0bc8e2b9..1802cce35d 100644 --- a/ios/PaymentMethodFactory.swift +++ b/ios/PaymentMethodFactory.swift @@ -61,6 +61,8 @@ class PaymentMethodFactory { return try createCashAppPaymentMethodParams() case STPPaymentMethodType.revolutPay: return try createRevolutPayPaymentMethodParams() + case STPPaymentMethodType.link: + return try createLinkPaymentMethodParams() // case STPPaymentMethodType.weChatPay: // return try createWeChatPayPaymentMethodParams() default: @@ -116,6 +118,8 @@ class PaymentMethodFactory { return nil case STPPaymentMethodType.revolutPay: return nil + case STPPaymentMethodType.link: + return nil default: throw PaymentMethodError.paymentNotSupported } @@ -380,6 +384,15 @@ class PaymentMethodFactory { return STPPaymentMethodParams(revolutPay: params, billingDetails: billingDetailsParams, metadata: metadata) } + private func createLinkPaymentMethodParams() throws -> STPPaymentMethodParams { + let params = STPPaymentMethodParams() + params.type = .link + params.link = STPPaymentMethodLinkParams() + params.billingDetails = billingDetailsParams + params.metadata = metadata + return params + } + func createMandateData() -> STPMandateDataParams? { if let mandateParams = paymentMethodData?["mandateData"] as? NSDictionary { if let customerAcceptanceParams = mandateParams["customerAcceptance"] as? NSDictionary { diff --git a/src/types/PaymentIntent.ts b/src/types/PaymentIntent.ts index 24ec62a40e..f9d06ad246 100644 --- a/src/types/PaymentIntent.ts +++ b/src/types/PaymentIntent.ts @@ -50,7 +50,8 @@ export type ConfirmParams = | PayPalParams | AffirmParams | CashAppParams - | RevolutPayParams; + | RevolutPayParams + | LinkParams; export type ConfirmOptions = PaymentMethod.ConfirmOptions; @@ -306,6 +307,15 @@ export type RevolutPayParams = { }; }; +export interface LinkParams { + paymentMethodType: 'Link'; + paymentMethodData?: { + billingDetails?: BillingDetails; + mandateData?: MandateData; + metadata?: MetaData; + }; +} + export type CollectBankAccountParams = { paymentMethodType: 'USBankAccount'; paymentMethodData: { diff --git a/src/types/PaymentMethod.ts b/src/types/PaymentMethod.ts index 48ca28e1f5..5a72d7f980 100644 --- a/src/types/PaymentMethod.ts +++ b/src/types/PaymentMethod.ts @@ -42,7 +42,8 @@ export type CreateParams = | USBankAccountParams | PayPalParams | AffirmParams - | CashAppParams; + | CashAppParams + | LinkParams; export type ConfirmParams = CreateParams; @@ -213,6 +214,13 @@ export type CashAppParams = { }; }; +export interface LinkParams { + paymentMethodType: 'Link'; + paymentMethodData?: { + billingDetails?: BillingDetails; + }; +} + export interface AuBecsDebitResult { fingerprint?: string; last4?: string; @@ -292,6 +300,7 @@ export type Type = | 'Upi' | 'USBankAccount' | 'PayPal' + | 'Link' | 'Unknown'; export type CollectBankAccountParams = { From fedfbe1e425e6f340c6999e6a7df4bb1e9c34799 Mon Sep 17 00:00:00 2001 From: Hugo EXTRAT Date: Thu, 9 Apr 2026 17:51:15 +0200 Subject: [PATCH 2/5] lookup --- .../LinkConsumerRepository.kt | 86 ++++++++++ .../PaymentMethodCreateParamsFactory.kt | 12 +- .../reactnativestripesdk/StripeSdkModule.kt | 132 +++++++++++++++ .../NativeStripeSdkModuleSpec.java | 16 ++ ios/PaymentMethodFactory.swift | 13 +- ios/StripeSdk.mm | 28 ++++ ios/StripeSdkImpl+Link.swift | 155 ++++++++++++++++++ src/functions.ts | 33 ++++ src/specs/NativeStripeSdkModule.ts | 18 ++ src/types/Link.ts | 53 ++++++ src/types/PaymentIntent.ts | 6 + src/types/PaymentMethod.ts | 6 + src/types/index.ts | 1 + 13 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt create mode 100644 ios/StripeSdkImpl+Link.swift create mode 100644 src/types/Link.ts diff --git a/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt b/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt new file mode 100644 index 0000000000..68f847104b --- /dev/null +++ b/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt @@ -0,0 +1,86 @@ +package com.reactnativestripesdk + +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.util.Locale +import java.util.UUID + +internal class LinkConsumerRepository(private val publishableKey: String) { + private val apiBase = "https://api.stripe.com/v1" + + fun lookupConsumer(email: String): JSONObject { + val params = buildString { + append("email_address=${encode(email)}") + append("&request_surface=android_payment_element") + append("&session_id=${UUID.randomUUID()}") + } + return post("$apiBase/consumers/sessions/lookup", params, publishableKey) + } + + fun startVerification(consumerSessionClientSecret: String, consumerAccountPublishableKey: String?) { + val params = buildString { + append("credentials[consumer_session_client_secret]=${encode(consumerSessionClientSecret)}") + append("&type=SMS") + append("&locale=${encode(Locale.getDefault().toLanguageTag())}") + } + post("$apiBase/consumers/sessions/start_verification", params, consumerAccountPublishableKey ?: publishableKey) + } + + fun confirmVerification(consumerSessionClientSecret: String, code: String, consumerAccountPublishableKey: String?): JSONObject { + val params = buildString { + append("credentials[consumer_session_client_secret]=${encode(consumerSessionClientSecret)}") + append("&type=SMS") + append("&code=${encode(code)}") + append("&request_surface=android_payment_element") + } + return post("$apiBase/consumers/sessions/confirm_verification", params, consumerAccountPublishableKey ?: publishableKey) + } + + fun listPaymentDetails(consumerSessionClientSecret: String, consumerAccountPublishableKey: String?): JSONArray { + val params = buildString { + append("credentials[consumer_session_client_secret]=${encode(consumerSessionClientSecret)}") + append("&request_surface=android_payment_element") + append("&types[]=CARD") + append("&types[]=BANK_ACCOUNT") + } + val json = post("$apiBase/consumers/payment_details/list", params, consumerAccountPublishableKey ?: publishableKey) + return json.optJSONArray("redactedPaymentDetails") ?: JSONArray() + } + + private fun post(url: String, body: String, apiKey: String): JSONObject { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + connection.doOutput = true + connection.connectTimeout = 30_000 + connection.readTimeout = 30_000 + + OutputStreamWriter(connection.outputStream).use { it.write(body) } + + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val response = BufferedReader(InputStreamReader(stream)).readText() + val json = JSONObject(response) + + if (code !in 200..299) { + val error = json.optJSONObject("error") + val msg = error?.optString("message") ?: "HTTP $code" + throw Exception(msg) + } + + return json + } finally { + connection.disconnect() + } + } + + private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") +} diff --git a/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt b/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt index 126b11e177..cef6878873 100644 --- a/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt +++ b/android/src/main/java/com/reactnativestripesdk/PaymentMethodCreateParamsFactory.kt @@ -238,11 +238,19 @@ class PaymentMethodCreateParamsFactory( ) @Throws(PaymentMethodCreateParamsException::class) - private fun createLinkParams(): PaymentMethodCreateParams = - PaymentMethodCreateParams.createLink( + private fun createLinkParams(): PaymentMethodCreateParams { + val paymentDetailsId = getValOr(paymentMethodData, "paymentDetailsId", null) + val consumerSessionClientSecret = getValOr(paymentMethodData, "consumerSessionClientSecret", null) + val cvc = getValOr(paymentMethodData, "cvc", null) + val extraParams: Map? = if (cvc != null) mapOf("card" to mapOf("cvc" to cvc)) else null + return PaymentMethodCreateParams.createLink( + paymentDetailsId = paymentDetailsId, + consumerSessionClientSecret = consumerSessionClientSecret, billingDetails = billingDetailsParams, + extraParams = extraParams, metadata = metadataParams, ) + } @Throws(PaymentMethodCreateParamsException::class) fun createParams( diff --git a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt index 3f4d523427..5ac725e967 100644 --- a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt +++ b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt @@ -81,6 +81,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.json.JSONArray import org.json.JSONObject @ReactModule(name = StripeSdkModule.NAME) @@ -1856,6 +1857,137 @@ class StripeSdkModule( ) } + @ReactMethod + override fun lookupLinkConsumer( + email: String, + promise: Promise, + ) { + CoroutineScope(Dispatchers.IO).launch { + try { + val json = LinkConsumerRepository(publishableKey).lookupConsumer(email) + val result = WritableNativeMap() + val exists = json.optBoolean("exists", false) + if (exists) { + result.putBoolean("exists", true) + val session = json.optJSONObject("consumerSession") + result.putString("consumerSessionClientSecret", session?.optString("clientSecret") ?: "") + result.putString("redactedPhoneNumber", session?.optString("redactedFormattedPhoneNumber") ?: "") + val consumerKey = json.optString("publishableKey", "") + if (consumerKey.isNotBlank()) { + result.putString("consumerAccountPublishableKey", consumerKey) + } + } else { + result.putBoolean("exists", false) + } + promise.resolve(result) + } catch (e: Exception) { + promise.resolve(createError("Failed", e)) + } + } + } + + @ReactMethod + override fun startLinkOTPVerification( + params: ReadableMap, + promise: Promise, + ) { + val consumerSessionClientSecret = params.getString("consumerSessionClientSecret") + if (consumerSessionClientSecret == null) { + promise.resolve(createError("Failed", "consumerSessionClientSecret is required")) + return + } + val consumerAccountPublishableKey = if (params.hasKey("consumerAccountPublishableKey")) params.getString("consumerAccountPublishableKey") else null + CoroutineScope(Dispatchers.IO).launch { + try { + LinkConsumerRepository(publishableKey).startVerification(consumerSessionClientSecret, consumerAccountPublishableKey) + promise.resolve(WritableNativeMap()) + } catch (e: Exception) { + promise.resolve(createError("Failed", e)) + } + } + } + + @ReactMethod + override fun confirmLinkOTPVerification( + params: ReadableMap, + promise: Promise, + ) { + val consumerSessionClientSecret = params.getString("consumerSessionClientSecret") + val code = params.getString("code") + if (consumerSessionClientSecret == null || code == null) { + promise.resolve(createError("Failed", "consumerSessionClientSecret and code are required")) + return + } + val consumerAccountPublishableKey = if (params.hasKey("consumerAccountPublishableKey")) params.getString("consumerAccountPublishableKey") else null + CoroutineScope(Dispatchers.IO).launch { + try { + val json = LinkConsumerRepository(publishableKey).confirmVerification(consumerSessionClientSecret, code, consumerAccountPublishableKey) + val result = WritableNativeMap() + val session = json.optJSONObject("consumerSession") + result.putString("consumerSessionClientSecret", session?.optString("clientSecret") ?: consumerSessionClientSecret) + promise.resolve(result) + } catch (e: Exception) { + promise.resolve(createError("Failed", e)) + } + } + } + + @ReactMethod + override fun listLinkPaymentMethods( + params: ReadableMap, + promise: Promise, + ) { + val consumerSessionClientSecret = params.getString("consumerSessionClientSecret") + if (consumerSessionClientSecret == null) { + promise.resolve(createError("Failed", "consumerSessionClientSecret is required")) + return + } + val consumerAccountPublishableKey = if (params.hasKey("consumerAccountPublishableKey")) params.getString("consumerAccountPublishableKey") else null + CoroutineScope(Dispatchers.IO).launch { + try { + val detailsArray = LinkConsumerRepository(publishableKey).listPaymentDetails(consumerSessionClientSecret, consumerAccountPublishableKey) + val methods = WritableNativeArray() + for (i in 0 until detailsArray.length()) { + val detail = detailsArray.getJSONObject(i) + val id = detail.getString("id") + val isDefault = detail.optBoolean("isDefault", false) + val type = detail.optString("type", "") + val method = WritableNativeMap() + method.putString("id", id) + method.putBoolean("isDefault", isDefault) + when (type.uppercase()) { + "CARD" -> { + val card = detail.optJSONObject("cardDetails") + method.putString("type", "Card") + method.putString("last4", card?.optString("last4") ?: "") + if (card != null) { + method.putString("brand", card.optString("brand")) + method.putInt("expYear", card.optInt("expYear")) + method.putInt("expMonth", card.optInt("expMonth")) + } + } + "BANK_ACCOUNT" -> { + val bank = detail.optJSONObject("bankAccountDetails") + method.putString("type", "BankAccount") + method.putString("last4", bank?.optString("last4") ?: "") + method.putString("bankName", bank?.optString("bankName") ?: "") + } + else -> { + method.putString("type", "Unknown") + method.putString("last4", "") + } + } + methods.pushMap(method) + } + val result = WritableNativeMap() + result.putArray("paymentMethods", methods) + promise.resolve(result) + } catch (e: Exception) { + promise.resolve(createError("Failed", e)) + } + } + } + companion object { const val NAME = NativeStripeSdkModuleSpec.NAME private const val TAG = "StripeSdkModule" diff --git a/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java b/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java index cd2f1ed510..1116fea17a 100644 --- a/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java +++ b/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java @@ -308,4 +308,20 @@ private void invoke(String eventName) { @ReactMethod @DoNotStrip public abstract void getNetworksForCard(ReadableMap params, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void lookupLinkConsumer(String email, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void startLinkOTPVerification(ReadableMap params, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void confirmLinkOTPVerification(ReadableMap params, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void listLinkPaymentMethods(ReadableMap params, Promise promise); } diff --git a/ios/PaymentMethodFactory.swift b/ios/PaymentMethodFactory.swift index 1802cce35d..79ff7dd51c 100644 --- a/ios/PaymentMethodFactory.swift +++ b/ios/PaymentMethodFactory.swift @@ -1,5 +1,6 @@ import Foundation import Stripe +@_spi(STP) import StripePayments class PaymentMethodFactory { var billingDetailsParams: STPPaymentMethodBillingDetails? @@ -387,7 +388,17 @@ class PaymentMethodFactory { private func createLinkPaymentMethodParams() throws -> STPPaymentMethodParams { let params = STPPaymentMethodParams() params.type = .link - params.link = STPPaymentMethodLinkParams() + let linkParams = STPPaymentMethodLinkParams() + if let paymentDetailsId = self.paymentMethodData?["paymentDetailsId"] as? String { + linkParams.paymentDetailsID = paymentDetailsId + } + if let consumerSessionClientSecret = self.paymentMethodData?["consumerSessionClientSecret"] as? String { + linkParams.credentials = ["consumer_session_client_secret": consumerSessionClientSecret] + } + if let cvc = self.paymentMethodData?["cvc"] as? String { + linkParams.additionalAPIParameters["card"] = ["cvc": cvc] + } + params.link = linkParams params.billingDetails = billingDetailsParams params.metadata = metadata return params diff --git a/ios/StripeSdk.mm b/ios/StripeSdk.mm index 743e383a54..405568e9ee 100644 --- a/ios/StripeSdk.mm +++ b/ios/StripeSdk.mm @@ -497,6 +497,34 @@ - (instancetype)init [StripeSdkImpl.shared getNetworksForCard:params resolver:resolve rejecter:reject]; } +RCT_EXPORT_METHOD(lookupLinkConsumer:(nonnull NSString *)email + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject) +{ + [StripeSdkImpl.shared lookupLinkConsumer:email resolver:resolve rejecter:reject]; +} + +RCT_EXPORT_METHOD(startLinkOTPVerification:(nonnull NSDictionary *)params + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject) +{ + [StripeSdkImpl.shared startLinkOTPVerification:params resolver:resolve rejecter:reject]; +} + +RCT_EXPORT_METHOD(confirmLinkOTPVerification:(nonnull NSDictionary *)params + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject) +{ + [StripeSdkImpl.shared confirmLinkOTPVerification:params resolver:resolve rejecter:reject]; +} + +RCT_EXPORT_METHOD(listLinkPaymentMethods:(nonnull NSDictionary *)params + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject) +{ + [StripeSdkImpl.shared listLinkPaymentMethods:params resolver:resolve rejecter:reject]; +} + /* clang-format on */ #ifdef RCT_NEW_ARCH_ENABLED diff --git a/ios/StripeSdkImpl+Link.swift b/ios/StripeSdkImpl+Link.swift new file mode 100644 index 0000000000..23e1b006e0 --- /dev/null +++ b/ios/StripeSdkImpl+Link.swift @@ -0,0 +1,155 @@ +// +// StripeSdkImpl+Link.swift +// stripe-react-native +// + +import Foundation +@_spi(STP) import StripePaymentSheet + +extension StripeSdkImpl { + + @objc(lookupLinkConsumer:resolver:rejecter:) + public func lookupLinkConsumer( + email: String, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + ConsumerSession.lookupSession( + for: email, + emailSource: .customerObject, + sessionID: UUID().uuidString, + with: STPAPIClient.shared, + cookieStore: LinkSecureCookieStore.shared, + useMobileEndpoints: false + ) { result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found(let session): + var response: [String: Any] = [ + "exists": true, + "consumerSessionClientSecret": session.consumerSession.clientSecret, + "redactedPhoneNumber": session.consumerSession.redactedFormattedPhoneNumber, + ] + response["consumerAccountPublishableKey"] = session.publishableKey + resolve(response) + case .notFound, .noAvailableLookupParams: + resolve(["exists": false]) + } + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } + + @objc(startLinkOTPVerification:resolver:rejecter:) + public func startLinkOTPVerification( + params: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let consumerSessionClientSecret = params["consumerSessionClientSecret"] as? String else { + resolve(Errors.createError(ErrorType.Failed, "consumerSessionClientSecret is required")) + return + } + let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + + STPAPIClient.shared.startVerification( + for: consumerSessionClientSecret, + type: .sms, + locale: .autoupdatingCurrent, + cookieStore: LinkSecureCookieStore.shared, + consumerAccountPublishableKey: consumerAccountPublishableKey + ) { result in + switch result { + case .success: + resolve([:]) + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } + + @objc(confirmLinkOTPVerification:resolver:rejecter:) + public func confirmLinkOTPVerification( + params: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let consumerSessionClientSecret = params["consumerSessionClientSecret"] as? String, + let code = params["code"] as? String else { + resolve(Errors.createError(ErrorType.Failed, "consumerSessionClientSecret and code are required")) + return + } + let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + + STPAPIClient.shared.confirmSMSVerification( + for: consumerSessionClientSecret, + with: code, + cookieStore: LinkSecureCookieStore.shared, + consumerAccountPublishableKey: consumerAccountPublishableKey + ) { result in + switch result { + case .success(let session): + resolve(["consumerSessionClientSecret": session.clientSecret]) + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } + + @objc(listLinkPaymentMethods:resolver:rejecter:) + public func listLinkPaymentMethods( + params: NSDictionary, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + guard let consumerSessionClientSecret = params["consumerSessionClientSecret"] as? String else { + resolve(Errors.createError(ErrorType.Failed, "consumerSessionClientSecret is required")) + return + } + let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + + STPAPIClient.shared.listPaymentDetails( + for: consumerSessionClientSecret, + supportedPaymentDetailsTypes: [.card, .bankAccount], + consumerAccountPublishableKey: consumerAccountPublishableKey + ) { result in + switch result { + case .success(let paymentDetails): + let methods: [[String: Any]] = paymentDetails.compactMap { detail in + switch detail.details { + case .card(let card): + return [ + "id": detail.stripeID, + "type": "Card", + "last4": card.last4, + "isDefault": detail.isDefault, + "brand": card.brand, + "expYear": card.expiryYear, + "expMonth": card.expiryMonth, + ] + case .bankAccount(let bank): + return [ + "id": detail.stripeID, + "type": "BankAccount", + "last4": bank.last4, + "isDefault": detail.isDefault, + "bankName": bank.name, + ] + case .unparsable: + return [ + "id": detail.stripeID, + "type": "Unknown", + "last4": "", + "isDefault": detail.isDefault, + ] + } + } + resolve(["paymentMethods": methods]) + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } +} diff --git a/src/functions.ts b/src/functions.ts index 697da135f7..d24610cf75 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -37,6 +37,15 @@ import type { import { Platform, EventSubscription } from 'react-native'; import type { CollectFinancialConnectionsAccountsParams } from './types/FinancialConnections'; import type { CollectBankAccountTokenParams } from './types/PaymentMethod'; +import type { + LookupLinkConsumerResult, + StartLinkOTPVerificationParams, + StartLinkOTPVerificationResult, + ConfirmLinkOTPVerificationParams, + ConfirmLinkOTPVerificationResult, + ListLinkPaymentMethodsParams, + ListLinkPaymentMethodsResult, +} from './types/Link'; import { addListener } from './events'; export const createPaymentMethod = async ( @@ -960,3 +969,27 @@ export const getNetworksForCard = async (params: { return brand; }); }; + +export const lookupLinkConsumer = async ( + email: string +): Promise => { + return await NativeStripeSdk.lookupLinkConsumer(email); +}; + +export const startLinkOTPVerification = async ( + params: StartLinkOTPVerificationParams +): Promise => { + return await NativeStripeSdk.startLinkOTPVerification(params); +}; + +export const confirmLinkOTPVerification = async ( + params: ConfirmLinkOTPVerificationParams +): Promise => { + return await NativeStripeSdk.confirmLinkOTPVerification(params); +}; + +export const listLinkPaymentMethods = async ( + params: ListLinkPaymentMethodsParams +): Promise => { + return await NativeStripeSdk.listLinkPaymentMethods(params); +}; diff --git a/src/specs/NativeStripeSdkModule.ts b/src/specs/NativeStripeSdkModule.ts index f1140bd675..d5ba68f055 100644 --- a/src/specs/NativeStripeSdkModule.ts +++ b/src/specs/NativeStripeSdkModule.ts @@ -35,6 +35,13 @@ import type { Token, VerifyMicrodepositsParams, CreateRadarSessionResult, + LookupLinkConsumerResult, + StartLinkOTPVerificationParams, + StartLinkOTPVerificationResult, + ConfirmLinkOTPVerificationParams, + ConfirmLinkOTPVerificationResult, + ListLinkPaymentMethodsParams, + ListLinkPaymentMethodsResult, } from '../types'; import type { EmbeddedPaymentElementConfiguration, @@ -231,6 +238,17 @@ export interface Spec extends TurboModule { params: UnsafeObject<{ cardNumber: string }> ): Promise; + lookupLinkConsumer(email: string): Promise; + startLinkOTPVerification( + params: UnsafeObject + ): Promise; + confirmLinkOTPVerification( + params: UnsafeObject + ): Promise; + listLinkPaymentMethods( + params: UnsafeObject + ): Promise; + // Events addListener: (eventType: string) => void; removeListeners: (count: number) => void; diff --git a/src/types/Link.ts b/src/types/Link.ts new file mode 100644 index 0000000000..b21a222943 --- /dev/null +++ b/src/types/Link.ts @@ -0,0 +1,53 @@ +import type { StripeError } from '.'; + +export type LinkPaymentMethod = { + id: string; + type: 'Card' | 'BankAccount' | 'Unknown'; + last4: string; + isDefault: boolean; + // Card specific + brand?: string; + expYear?: number; + expMonth?: number; + // Bank account specific + bankName?: string; +}; + +export type LookupLinkConsumerResult = + | { + exists: true; + consumerSessionClientSecret: string; + redactedPhoneNumber: string; + consumerAccountPublishableKey?: string; + error?: undefined; + } + | { exists: false; error?: undefined } + | { exists?: undefined; error: StripeError }; + +export type StartLinkOTPVerificationParams = { + consumerSessionClientSecret: string; + consumerAccountPublishableKey?: string; +}; + +export type StartLinkOTPVerificationResult = + | { error?: undefined } + | { error: StripeError }; + +export type ConfirmLinkOTPVerificationParams = { + consumerSessionClientSecret: string; + code: string; + consumerAccountPublishableKey?: string; +}; + +export type ConfirmLinkOTPVerificationResult = + | { consumerSessionClientSecret: string; error?: undefined } + | { error: StripeError }; + +export type ListLinkPaymentMethodsParams = { + consumerSessionClientSecret: string; + consumerAccountPublishableKey?: string; +}; + +export type ListLinkPaymentMethodsResult = + | { paymentMethods: LinkPaymentMethod[]; error?: undefined } + | { error: StripeError }; diff --git a/src/types/PaymentIntent.ts b/src/types/PaymentIntent.ts index f9d06ad246..9ea11de9b8 100644 --- a/src/types/PaymentIntent.ts +++ b/src/types/PaymentIntent.ts @@ -311,6 +311,12 @@ export interface LinkParams { paymentMethodType: 'Link'; paymentMethodData?: { billingDetails?: BillingDetails; + /** ID of the saved payment detail in the Link consumer's wallet. */ + paymentDetailsId?: string; + /** Client secret of the authenticated Link consumer session. */ + consumerSessionClientSecret?: string; + /** CVC for card payment details (optional). */ + cvc?: string; mandateData?: MandateData; metadata?: MetaData; }; diff --git a/src/types/PaymentMethod.ts b/src/types/PaymentMethod.ts index 5a72d7f980..f74d332c91 100644 --- a/src/types/PaymentMethod.ts +++ b/src/types/PaymentMethod.ts @@ -218,6 +218,12 @@ export interface LinkParams { paymentMethodType: 'Link'; paymentMethodData?: { billingDetails?: BillingDetails; + /** ID of the saved payment detail in the Link consumer's wallet. */ + paymentDetailsId?: string; + /** Client secret of the authenticated Link consumer session. */ + consumerSessionClientSecret?: string; + /** CVC for card payment details (optional). */ + cvc?: string; }; } diff --git a/src/types/index.ts b/src/types/index.ts index 74d7d59ba9..98f0d4a7aa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -49,6 +49,7 @@ export { export * from './PushProvisioning'; export * from './Errors'; export * from './CustomerSheet'; +export * from './Link'; export type { Address, BillingDetails, From dac0d27537270781cccd69515f27bfdb9af3d79d Mon Sep 17 00:00:00 2001 From: Hugo EXTRAT Date: Thu, 9 Apr 2026 18:08:53 +0200 Subject: [PATCH 3/5] fix --- ios/StripeSdkImpl+Link.swift | 199 +++++++++++++++++++++++++---------- 1 file changed, 144 insertions(+), 55 deletions(-) diff --git a/ios/StripeSdkImpl+Link.swift b/ios/StripeSdkImpl+Link.swift index 23e1b006e0..3f5d936261 100644 --- a/ios/StripeSdkImpl+Link.swift +++ b/ios/StripeSdkImpl+Link.swift @@ -4,36 +4,98 @@ // import Foundation -@_spi(STP) import StripePaymentSheet +import Stripe extension StripeSdkImpl { + // MARK: - REST helpers + + private func linkPost( + endpoint: String, + bodyParts: [(String, String)], + apiKey: String, + completion: @escaping (Result<[String: Any], Error>) -> Void + ) { + guard let url = URL(string: "https://api.stripe.com/v1/\(endpoint)") else { + completion(.failure(NSError(domain: "LinkError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let body = bodyParts.map { key, value in + "\(linkFormEncode(key))=\(linkFormEncode(value))" + }.joined(separator: "&") + request.httpBody = body.data(using: .utf8) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + completion(.failure(NSError(domain: "LinkError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"]))) + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + let errorObj = json["error"] as? [String: Any] + let msg = errorObj?["message"] as? String ?? "HTTP \(httpResponse.statusCode)" + completion(.failure(NSError(domain: "LinkError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: msg]))) + return + } + completion(.success(json)) + }.resume() + } + + private func linkFormEncode(_ s: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s + } + + private func linkApiKey(consumerAccountPublishableKey: String?) -> String { + return consumerAccountPublishableKey ?? STPAPIClient.shared.publishableKey ?? "" + } + + // MARK: - Lookup + @objc(lookupLinkConsumer:resolver:rejecter:) public func lookupLinkConsumer( email: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock ) { - ConsumerSession.lookupSession( - for: email, - emailSource: .customerObject, - sessionID: UUID().uuidString, - with: STPAPIClient.shared, - cookieStore: LinkSecureCookieStore.shared, - useMobileEndpoints: false - ) { result in + let apiKey = STPAPIClient.shared.publishableKey ?? "" + let bodyParts: [(String, String)] = [ + ("email_address", email.lowercased()), + ("request_surface", "ios_payment_element"), + ("session_id", UUID().uuidString), + ] + + linkPost(endpoint: "consumers/sessions/lookup", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { - case .success(let lookupResponse): - switch lookupResponse.responseType { - case .found(let session): + case .success(let json): + let exists = json["exists"] as? Bool ?? false + if exists { + guard let session = json["consumerSession"] as? [String: Any] else { + resolve(["exists": false]) + return + } var response: [String: Any] = [ "exists": true, - "consumerSessionClientSecret": session.consumerSession.clientSecret, - "redactedPhoneNumber": session.consumerSession.redactedFormattedPhoneNumber, + "consumerSessionClientSecret": session["clientSecret"] as? String ?? "", + "redactedPhoneNumber": session["redactedFormattedPhoneNumber"] as? String ?? "", ] - response["consumerAccountPublishableKey"] = session.publishableKey + if let consumerKey = json["publishableKey"] as? String, !consumerKey.isEmpty { + response["consumerAccountPublishableKey"] = consumerKey + } resolve(response) - case .notFound, .noAvailableLookupParams: + } else { resolve(["exists": false]) } case .failure(let error): @@ -42,6 +104,8 @@ extension StripeSdkImpl { } } + // MARK: - Start OTP + @objc(startLinkOTPVerification:resolver:rejecter:) public func startLinkOTPVerification( params: NSDictionary, @@ -53,14 +117,16 @@ extension StripeSdkImpl { return } let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) - STPAPIClient.shared.startVerification( - for: consumerSessionClientSecret, - type: .sms, - locale: .autoupdatingCurrent, - cookieStore: LinkSecureCookieStore.shared, - consumerAccountPublishableKey: consumerAccountPublishableKey - ) { result in + let locale = Locale.autoupdatingCurrent.identifier.replacingOccurrences(of: "_", with: "-") + let bodyParts: [(String, String)] = [ + ("credentials[consumer_session_client_secret]", consumerSessionClientSecret), + ("type", "SMS"), + ("locale", locale), + ] + + linkPost(endpoint: "consumers/sessions/start_verification", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { case .success: resolve([:]) @@ -70,6 +136,8 @@ extension StripeSdkImpl { } } + // MARK: - Confirm OTP + @objc(confirmLinkOTPVerification:resolver:rejecter:) public func confirmLinkOTPVerification( params: NSDictionary, @@ -82,22 +150,29 @@ extension StripeSdkImpl { return } let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) - STPAPIClient.shared.confirmSMSVerification( - for: consumerSessionClientSecret, - with: code, - cookieStore: LinkSecureCookieStore.shared, - consumerAccountPublishableKey: consumerAccountPublishableKey - ) { result in + let bodyParts: [(String, String)] = [ + ("credentials[consumer_session_client_secret]", consumerSessionClientSecret), + ("type", "SMS"), + ("code", code), + ("request_surface", "ios_payment_element"), + ] + + linkPost(endpoint: "consumers/sessions/confirm_verification", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { - case .success(let session): - resolve(["consumerSessionClientSecret": session.clientSecret]) + case .success(let json): + let session = json["consumerSession"] as? [String: Any] + let newSecret = session?["clientSecret"] as? String ?? consumerSessionClientSecret + resolve(["consumerSessionClientSecret": newSecret]) case .failure(let error): resolve(Errors.createError(ErrorType.Failed, error)) } } } + // MARK: - List payment methods + @objc(listLinkPaymentMethods:resolver:rejecter:) public func listLinkPaymentMethods( params: NSDictionary, @@ -109,40 +184,54 @@ extension StripeSdkImpl { return } let consumerAccountPublishableKey = params["consumerAccountPublishableKey"] as? String + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) - STPAPIClient.shared.listPaymentDetails( - for: consumerSessionClientSecret, - supportedPaymentDetailsTypes: [.card, .bankAccount], - consumerAccountPublishableKey: consumerAccountPublishableKey - ) { result in + let bodyParts: [(String, String)] = [ + ("credentials[consumer_session_client_secret]", consumerSessionClientSecret), + ("request_surface", "ios_payment_element"), + ("types[]", "CARD"), + ("types[]", "BANK_ACCOUNT"), + ] + + linkPost(endpoint: "consumers/payment_details/list", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { - case .success(let paymentDetails): - let methods: [[String: Any]] = paymentDetails.compactMap { detail in - switch detail.details { - case .card(let card): - return [ - "id": detail.stripeID, + case .success(let json): + let detailsArray = json["redactedPaymentDetails"] as? [[String: Any]] ?? [] + let methods: [[String: Any]] = detailsArray.compactMap { detail in + let id = detail["id"] as? String ?? "" + let isDefault = detail["isDefault"] as? Bool ?? false + let type = (detail["type"] as? String ?? "").uppercased() + + switch type { + case "CARD": + let card = detail["cardDetails"] as? [String: Any] + var method: [String: Any] = [ + "id": id, "type": "Card", - "last4": card.last4, - "isDefault": detail.isDefault, - "brand": card.brand, - "expYear": card.expiryYear, - "expMonth": card.expiryMonth, + "last4": card?["last4"] as? String ?? "", + "isDefault": isDefault, ] - case .bankAccount(let bank): + if let card = card { + method["brand"] = card["brand"] as? String ?? "" + method["expYear"] = card["expYear"] as? Int ?? 0 + method["expMonth"] = card["expMonth"] as? Int ?? 0 + } + return method + case "BANK_ACCOUNT": + let bank = detail["bankAccountDetails"] as? [String: Any] return [ - "id": detail.stripeID, + "id": id, "type": "BankAccount", - "last4": bank.last4, - "isDefault": detail.isDefault, - "bankName": bank.name, + "last4": bank?["last4"] as? String ?? "", + "isDefault": isDefault, + "bankName": bank?["bankName"] as? String ?? "", ] - case .unparsable: + default: return [ - "id": detail.stripeID, + "id": id, "type": "Unknown", "last4": "", - "isDefault": detail.isDefault, + "isDefault": isDefault, ] } } From 21881ab2694279af2492c4421f710f8f7db07c79 Mon Sep 17 00:00:00 2001 From: Hugo EXTRAT Date: Thu, 9 Apr 2026 18:35:47 +0200 Subject: [PATCH 4/5] fix: snake_case --- .../LinkConsumerRepository.kt | 5 ++-- .../reactnativestripesdk/StripeSdkModule.kt | 24 ++++++++--------- ios/StripeSdkImpl+Link.swift | 27 ++++++++++--------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt b/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt index 68f847104b..c06d2d8470 100644 --- a/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt +++ b/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt @@ -16,7 +16,8 @@ internal class LinkConsumerRepository(private val publishableKey: String) { fun lookupConsumer(email: String): JSONObject { val params = buildString { - append("email_address=${encode(email)}") + append("email_address=${encode(email.lowercase())}") + append("&email_source=customer_object") append("&request_surface=android_payment_element") append("&session_id=${UUID.randomUUID()}") } @@ -50,7 +51,7 @@ internal class LinkConsumerRepository(private val publishableKey: String) { append("&types[]=BANK_ACCOUNT") } val json = post("$apiBase/consumers/payment_details/list", params, consumerAccountPublishableKey ?: publishableKey) - return json.optJSONArray("redactedPaymentDetails") ?: JSONArray() + return json.optJSONArray("redacted_payment_details") ?: JSONArray() } private fun post(url: String, body: String, apiKey: String): JSONObject { diff --git a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt index 5ac725e967..fd821ec676 100644 --- a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt +++ b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt @@ -1869,10 +1869,10 @@ class StripeSdkModule( val exists = json.optBoolean("exists", false) if (exists) { result.putBoolean("exists", true) - val session = json.optJSONObject("consumerSession") - result.putString("consumerSessionClientSecret", session?.optString("clientSecret") ?: "") - result.putString("redactedPhoneNumber", session?.optString("redactedFormattedPhoneNumber") ?: "") - val consumerKey = json.optString("publishableKey", "") + val session = json.optJSONObject("consumer_session") + result.putString("consumerSessionClientSecret", session?.optString("client_secret") ?: "") + result.putString("redactedPhoneNumber", session?.optString("redacted_formatted_phone_number") ?: "") + val consumerKey = json.optString("publishable_key", "") if (consumerKey.isNotBlank()) { result.putString("consumerAccountPublishableKey", consumerKey) } @@ -1923,8 +1923,8 @@ class StripeSdkModule( try { val json = LinkConsumerRepository(publishableKey).confirmVerification(consumerSessionClientSecret, code, consumerAccountPublishableKey) val result = WritableNativeMap() - val session = json.optJSONObject("consumerSession") - result.putString("consumerSessionClientSecret", session?.optString("clientSecret") ?: consumerSessionClientSecret) + val session = json.optJSONObject("consumer_session") + result.putString("consumerSessionClientSecret", session?.optString("client_secret") ?: consumerSessionClientSecret) promise.resolve(result) } catch (e: Exception) { promise.resolve(createError("Failed", e)) @@ -1950,27 +1950,27 @@ class StripeSdkModule( for (i in 0 until detailsArray.length()) { val detail = detailsArray.getJSONObject(i) val id = detail.getString("id") - val isDefault = detail.optBoolean("isDefault", false) + val isDefault = detail.optBoolean("is_default", false) val type = detail.optString("type", "") val method = WritableNativeMap() method.putString("id", id) method.putBoolean("isDefault", isDefault) when (type.uppercase()) { "CARD" -> { - val card = detail.optJSONObject("cardDetails") + val card = detail.optJSONObject("card_details") method.putString("type", "Card") method.putString("last4", card?.optString("last4") ?: "") if (card != null) { method.putString("brand", card.optString("brand")) - method.putInt("expYear", card.optInt("expYear")) - method.putInt("expMonth", card.optInt("expMonth")) + method.putInt("expYear", card.optInt("exp_year")) + method.putInt("expMonth", card.optInt("exp_month")) } } "BANK_ACCOUNT" -> { - val bank = detail.optJSONObject("bankAccountDetails") + val bank = detail.optJSONObject("bank_account_details") method.putString("type", "BankAccount") method.putString("last4", bank?.optString("last4") ?: "") - method.putString("bankName", bank?.optString("bankName") ?: "") + method.putString("bankName", bank?.optString("bank_name") ?: "") } else -> { method.putString("type", "Unknown") diff --git a/ios/StripeSdkImpl+Link.swift b/ios/StripeSdkImpl+Link.swift index 3f5d936261..c633a867fe 100644 --- a/ios/StripeSdkImpl+Link.swift +++ b/ios/StripeSdkImpl+Link.swift @@ -73,6 +73,7 @@ extension StripeSdkImpl { let apiKey = STPAPIClient.shared.publishableKey ?? "" let bodyParts: [(String, String)] = [ ("email_address", email.lowercased()), + ("email_source", "customer_object"), ("request_surface", "ios_payment_element"), ("session_id", UUID().uuidString), ] @@ -82,16 +83,16 @@ extension StripeSdkImpl { case .success(let json): let exists = json["exists"] as? Bool ?? false if exists { - guard let session = json["consumerSession"] as? [String: Any] else { + guard let session = json["consumer_session"] as? [String: Any] else { resolve(["exists": false]) return } var response: [String: Any] = [ "exists": true, - "consumerSessionClientSecret": session["clientSecret"] as? String ?? "", - "redactedPhoneNumber": session["redactedFormattedPhoneNumber"] as? String ?? "", + "consumerSessionClientSecret": session["client_secret"] as? String ?? "", + "redactedPhoneNumber": session["redacted_formatted_phone_number"] as? String ?? "", ] - if let consumerKey = json["publishableKey"] as? String, !consumerKey.isEmpty { + if let consumerKey = json["publishable_key"] as? String, !consumerKey.isEmpty { response["consumerAccountPublishableKey"] = consumerKey } resolve(response) @@ -162,8 +163,8 @@ extension StripeSdkImpl { linkPost(endpoint: "consumers/sessions/confirm_verification", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { case .success(let json): - let session = json["consumerSession"] as? [String: Any] - let newSecret = session?["clientSecret"] as? String ?? consumerSessionClientSecret + let session = json["consumer_session"] as? [String: Any] + let newSecret = session?["client_secret"] as? String ?? consumerSessionClientSecret resolve(["consumerSessionClientSecret": newSecret]) case .failure(let error): resolve(Errors.createError(ErrorType.Failed, error)) @@ -196,15 +197,15 @@ extension StripeSdkImpl { linkPost(endpoint: "consumers/payment_details/list", bodyParts: bodyParts, apiKey: apiKey) { result in switch result { case .success(let json): - let detailsArray = json["redactedPaymentDetails"] as? [[String: Any]] ?? [] + let detailsArray = json["redacted_payment_details"] as? [[String: Any]] ?? [] let methods: [[String: Any]] = detailsArray.compactMap { detail in let id = detail["id"] as? String ?? "" - let isDefault = detail["isDefault"] as? Bool ?? false + let isDefault = detail["is_default"] as? Bool ?? false let type = (detail["type"] as? String ?? "").uppercased() switch type { case "CARD": - let card = detail["cardDetails"] as? [String: Any] + let card = detail["card_details"] as? [String: Any] var method: [String: Any] = [ "id": id, "type": "Card", @@ -213,18 +214,18 @@ extension StripeSdkImpl { ] if let card = card { method["brand"] = card["brand"] as? String ?? "" - method["expYear"] = card["expYear"] as? Int ?? 0 - method["expMonth"] = card["expMonth"] as? Int ?? 0 + method["expYear"] = card["exp_year"] as? Int ?? 0 + method["expMonth"] = card["exp_month"] as? Int ?? 0 } return method case "BANK_ACCOUNT": - let bank = detail["bankAccountDetails"] as? [String: Any] + let bank = detail["bank_account_details"] as? [String: Any] return [ "id": id, "type": "BankAccount", "last4": bank?["last4"] as? String ?? "", "isDefault": isDefault, - "bankName": bank?["bankName"] as? String ?? "", + "bankName": bank?["bank_name"] as? String ?? "", ] default: return [ From 148c05a29775623aca0fc21931fed717f750b2e9 Mon Sep 17 00:00:00 2001 From: Hugo EXTRAT Date: Thu, 9 Apr 2026 19:13:22 +0200 Subject: [PATCH 5/5] debug --- ios/StripeSdkImpl+Link.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/StripeSdkImpl+Link.swift b/ios/StripeSdkImpl+Link.swift index c633a867fe..3f00c7b51a 100644 --- a/ios/StripeSdkImpl+Link.swift +++ b/ios/StripeSdkImpl+Link.swift @@ -36,6 +36,9 @@ extension StripeSdkImpl { completion(.failure(error)) return } + let rawBody = data.flatMap { String(data: $0, encoding: .utf8) } ?? "(empty)" + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + print("[LinkDebug] POST \(endpoint) (\(statusCode)) → \(rawBody)") guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { completion(.failure(NSError(domain: "LinkError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"])))