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..c06d2d8470 --- /dev/null +++ b/android/src/main/java/com/reactnativestripesdk/LinkConsumerRepository.kt @@ -0,0 +1,87 @@ +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.lowercase())}") + append("&email_source=customer_object") + 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("redacted_payment_details") ?: 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 9aa8f6c777..cef6878873 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,21 @@ class PaymentMethodCreateParamsFactory( metadata = metadataParams, ) + @Throws(PaymentMethodCreateParamsException::class) + 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( clientSecret: String, @@ -249,6 +265,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/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt index 3f4d523427..fd821ec676 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("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) + } + } 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("consumer_session") + result.putString("consumerSessionClientSecret", session?.optString("client_secret") ?: 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("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("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("exp_year")) + method.putInt("expMonth", card.optInt("exp_month")) + } + } + "BANK_ACCOUNT" -> { + val bank = detail.optJSONObject("bank_account_details") + method.putString("type", "BankAccount") + method.putString("last4", bank?.optString("last4") ?: "") + method.putString("bankName", bank?.optString("bank_name") ?: "") + } + 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 1d0bc8e2b9..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? @@ -61,6 +62,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 +119,8 @@ class PaymentMethodFactory { return nil case STPPaymentMethodType.revolutPay: return nil + case STPPaymentMethodType.link: + return nil default: throw PaymentMethodError.paymentNotSupported } @@ -380,6 +385,25 @@ class PaymentMethodFactory { return STPPaymentMethodParams(revolutPay: params, billingDetails: billingDetailsParams, metadata: metadata) } + private func createLinkPaymentMethodParams() throws -> STPPaymentMethodParams { + let params = STPPaymentMethodParams() + params.type = .link + 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 + } + func createMandateData() -> STPMandateDataParams? { if let mandateParams = paymentMethodData?["mandateData"] as? NSDictionary { if let customerAcceptanceParams = mandateParams["customerAcceptance"] as? NSDictionary { 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..3f00c7b51a --- /dev/null +++ b/ios/StripeSdkImpl+Link.swift @@ -0,0 +1,248 @@ +// +// StripeSdkImpl+Link.swift +// stripe-react-native +// + +import Foundation +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 + } + 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"]))) + 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 + ) { + 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), + ] + + linkPost(endpoint: "consumers/sessions/lookup", bodyParts: bodyParts, apiKey: apiKey) { result in + switch result { + case .success(let json): + let exists = json["exists"] as? Bool ?? false + if exists { + guard let session = json["consumer_session"] as? [String: Any] else { + resolve(["exists": false]) + return + } + var response: [String: Any] = [ + "exists": true, + "consumerSessionClientSecret": session["client_secret"] as? String ?? "", + "redactedPhoneNumber": session["redacted_formatted_phone_number"] as? String ?? "", + ] + if let consumerKey = json["publishable_key"] as? String, !consumerKey.isEmpty { + response["consumerAccountPublishableKey"] = consumerKey + } + resolve(response) + } else { + resolve(["exists": false]) + } + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } + + // MARK: - Start OTP + + @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 + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) + + 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([:]) + case .failure(let error): + resolve(Errors.createError(ErrorType.Failed, error)) + } + } + } + + // MARK: - Confirm OTP + + @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 + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) + + 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 json): + 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)) + } + } + } + + // MARK: - List payment methods + + @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 + let apiKey = linkApiKey(consumerAccountPublishableKey: consumerAccountPublishableKey) + + 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 json): + 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["is_default"] as? Bool ?? false + let type = (detail["type"] as? String ?? "").uppercased() + + switch type { + case "CARD": + let card = detail["card_details"] as? [String: Any] + var method: [String: Any] = [ + "id": id, + "type": "Card", + "last4": card?["last4"] as? String ?? "", + "isDefault": isDefault, + ] + if let card = card { + method["brand"] = card["brand"] as? String ?? "" + method["expYear"] = card["exp_year"] as? Int ?? 0 + method["expMonth"] = card["exp_month"] as? Int ?? 0 + } + return method + case "BANK_ACCOUNT": + let bank = detail["bank_account_details"] as? [String: Any] + return [ + "id": id, + "type": "BankAccount", + "last4": bank?["last4"] as? String ?? "", + "isDefault": isDefault, + "bankName": bank?["bank_name"] as? String ?? "", + ] + default: + return [ + "id": id, + "type": "Unknown", + "last4": "", + "isDefault": 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 24ec62a40e..9ea11de9b8 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,21 @@ export type RevolutPayParams = { }; }; +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; + }; +} + export type CollectBankAccountParams = { paymentMethodType: 'USBankAccount'; paymentMethodData: { diff --git a/src/types/PaymentMethod.ts b/src/types/PaymentMethod.ts index 48ca28e1f5..f74d332c91 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,19 @@ export type CashAppParams = { }; }; +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; + }; +} + export interface AuBecsDebitResult { fingerprint?: string; last4?: string; @@ -292,6 +306,7 @@ export type Type = | 'Upi' | 'USBankAccount' | 'PayPal' + | 'Link' | 'Unknown'; export type CollectBankAccountParams = { 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,