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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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<String, Any>? = 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,
Expand All @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
24 changes: 24 additions & 0 deletions ios/PaymentMethodFactory.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Stripe
@_spi(STP) import StripePayments

class PaymentMethodFactory {
var billingDetailsParams: STPPaymentMethodBillingDetails?
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -116,6 +119,8 @@ class PaymentMethodFactory {
return nil
case STPPaymentMethodType.revolutPay:
return nil
case STPPaymentMethodType.link:
return nil
default:
throw PaymentMethodError.paymentNotSupported
}
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions ios/StripeSdk.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading