Skip to content

Commit ffde7d6

Browse files
Twigzmliberatore
andauthored
Crypto Onramp Payment Display Data (#2132)
* Initial implementation of returning display data * Allow bank account as well * Initial implementation on both platforms * Remove bankIconCode * update for latest android changes * Fills out more of the `paymentDisplayData` implementation on iOS * Update api to be promise based * Fix up to use result * Update CryptoOnrampFlow.tsx * Fix errors * Uses alternate method for getting card brand * Cleanup * Update Onramp.ts * Update StripeSdkImpl.swift * Rename function * Update typing information * Refactors iOS `getCryptoTokenDisplayData` to return dictionary synchronously * Fix compilation issue * Update RN apis and kotlin parsing * Updates iOS for new payment token dictionary structure * Update CryptoOnrampFlow.tsx * Fix pr comments * Re-implement promise API * Fix objc name * Update to latest iOS SDK version * Update iOS version * update android sdk to 21.27.0 * Fix formatting --------- Co-authored-by: Michael Liberatore <[email protected]>
1 parent 130ac95 commit ffde7d6

File tree

9 files changed

+276
-11
lines changed

9 files changed

+276
-11
lines changed

android/src/main/java/com/reactnativestripesdk/FakeOnrampSdkModule.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ class FakeOnrampSdkModule(
118118
promise?.resolveNotImplemented()
119119
}
120120

121+
@ReactMethod
122+
override fun getCryptoTokenDisplayData(
123+
token: ReadableMap,
124+
promise: Promise,
125+
) {
126+
promise?.resolveNotImplemented()
127+
}
128+
121129
private fun Promise.resolveNotImplemented() {
122130
this.resolve(
123131
createFailedError(

android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ protected final void emitOnCheckoutClientSecretRequested(ReadableMap value) {
9393
@DoNotStrip
9494
public abstract void onrampAuthorize(String linkAuthIntentId, Promise promise);
9595

96+
@ReactMethod
97+
@DoNotStrip
98+
public abstract void getCryptoTokenDisplayData(ReadableMap token, Promise promise);
99+
96100
@ReactMethod
97101
@DoNotStrip
98102
public abstract void logout(Promise promise);

android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ import com.stripe.android.link.LinkAppearance
4949
import com.stripe.android.link.LinkAppearance.Colors
5050
import com.stripe.android.link.LinkAppearance.PrimaryButton
5151
import com.stripe.android.link.LinkAppearance.Style
52+
import com.stripe.android.link.LinkController.PaymentMethodPreview
53+
import com.stripe.android.link.PaymentMethodPreviewDetails
54+
import com.stripe.android.model.CardBrand
5255
import com.stripe.android.paymentsheet.PaymentSheet
5356
import kotlinx.coroutines.CompletableDeferred
5457
import kotlinx.coroutines.CoroutineScope
@@ -526,6 +529,80 @@ class OnrampSdkModule(
526529
presenter.authorize(linkAuthIntentId)
527530
}
528531

532+
@ReactMethod
533+
override fun getCryptoTokenDisplayData(
534+
token: ReadableMap,
535+
promise: Promise,
536+
) {
537+
val context = reactApplicationContext
538+
539+
val paymentDetails: PaymentMethodPreview? =
540+
when {
541+
token.hasKey("card") -> {
542+
val cardMap = token.getMap("card")
543+
if (cardMap != null) {
544+
val brand = cardMap.getString("brand") ?: ""
545+
val funding = cardMap.getString("funding") ?: ""
546+
val last4 = cardMap.getString("last4") ?: ""
547+
val cardBrand = CardBrand.fromCode(brand)
548+
549+
PaymentMethodPreview.create(
550+
context = context,
551+
details =
552+
PaymentMethodPreviewDetails.Card(
553+
brand = cardBrand,
554+
funding = funding,
555+
last4 = last4,
556+
),
557+
)
558+
} else {
559+
null
560+
}
561+
}
562+
token.hasKey("us_bank_account") -> {
563+
val bankMap = token.getMap("us_bank_account")
564+
if (bankMap != null) {
565+
val bankName = bankMap.getString("bank_name")
566+
val last4 = bankMap.getString("last4") ?: ""
567+
PaymentMethodPreview.create(
568+
context = context,
569+
details =
570+
PaymentMethodPreviewDetails.BankAccount(
571+
bankIconCode = null,
572+
bankName = bankName,
573+
last4 = last4,
574+
),
575+
)
576+
} else {
577+
null
578+
}
579+
}
580+
else -> null
581+
}
582+
583+
if (paymentDetails == null) {
584+
promise.resolve(
585+
createFailedError(
586+
IllegalArgumentException("Unsupported payment method"),
587+
),
588+
)
589+
return
590+
}
591+
592+
val icon =
593+
currentActivity
594+
?.let { ContextCompat.getDrawable(it, paymentDetails.iconRes) }
595+
?.let { "data:image/png;base64," + getBase64FromBitmap(getBitmapFromDrawable(it)) }
596+
597+
val displayData = Arguments.createMap()
598+
599+
displayData.putString("icon", icon)
600+
displayData.putString("label", paymentDetails.label)
601+
displayData.putString("sublabel", paymentDetails.sublabel)
602+
603+
promise.resolve(createResult("displayData", displayData))
604+
}
605+
529606
@ReactMethod
530607
override fun logout(promise: Promise) {
531608
val coordinator =

example/src/screens/Onramp/CryptoOnrampFlow.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export default function CryptoOnrampFlow() {
5555
createCryptoPaymentToken,
5656
performCheckout,
5757
authorize,
58+
getCryptoTokenDisplayData,
5859
logOut,
5960
isAuthError,
6061
} = useOnramp();
@@ -75,7 +76,7 @@ export default function CryptoOnrampFlow() {
7576
const [cardPaymentMethod] = useState('Card');
7677
const [bankAccountPaymentMethod] = useState('BankAccount');
7778

78-
const [paymentDisplayData, setPaymentDisplayData] =
79+
const [currentPaymentDisplayData, setCurrentPaymentDisplayData] =
7980
useState<PaymentOptionData | null>(null);
8081

8182
const [cryptoPaymentToken, setCryptoPaymentToken] = useState<string | null>(
@@ -133,6 +134,33 @@ export default function CryptoOnrampFlow() {
133134
}
134135
}, [userInfo.email, hasLinkAccount]);
135136

137+
const showPaymentData = useCallback(async () => {
138+
const cardParams: Onramp.CryptoPaymentToken = {
139+
card: {
140+
brand: 'visa',
141+
funding: 'credit',
142+
last4: '1234',
143+
},
144+
};
145+
146+
const bankParams: Onramp.CryptoPaymentToken = {
147+
us_bank_account: {
148+
bank_name: 'Bank of America',
149+
last4: '5678',
150+
},
151+
};
152+
153+
const cardData = (await getCryptoTokenDisplayData(cardParams)).displayData;
154+
const bankData = (await getCryptoTokenDisplayData(bankParams)).displayData;
155+
156+
if (cardData) {
157+
setCurrentPaymentDisplayData(cardData);
158+
console.log('Bank Payment Data:', bankData);
159+
} else {
160+
Alert.alert('No Payment Data', 'No payment data available to display.');
161+
}
162+
}, [getCryptoTokenDisplayData]);
163+
136164
const handlePresentVerification = useCallback(async () => {
137165
if (!userInfo.email) {
138166
showError('Please enter an email address first.');
@@ -299,7 +327,7 @@ export default function CryptoOnrampFlow() {
299327
if (result?.error) {
300328
showError(`Could not collect payment: ${result.error.message}.`);
301329
} else if (result?.displayData) {
302-
setPaymentDisplayData(result.displayData);
330+
setCurrentPaymentDisplayData(result.displayData);
303331
} else {
304332
showCanceled('Payment collection cancelled, please try again.');
305333
}
@@ -345,7 +373,8 @@ export default function CryptoOnrampFlow() {
345373
if (!customerId) missingItems.push('customer authentication');
346374
if (!walletAddress || !walletNetwork)
347375
missingItems.push('wallet address registration');
348-
if (!paymentDisplayData) missingItems.push('payment method selection');
376+
if (!currentPaymentDisplayData)
377+
missingItems.push('payment method selection');
349378
if (!cryptoPaymentToken) missingItems.push('crypto payment token creation');
350379
if (!authToken) missingItems.push('authentication token');
351380

@@ -359,7 +388,7 @@ export default function CryptoOnrampFlow() {
359388
customerId,
360389
walletAddress,
361390
walletNetwork,
362-
paymentDisplayData,
391+
currentPaymentDisplayData,
363392
cryptoPaymentToken,
364393
authToken,
365394
]);
@@ -480,7 +509,7 @@ export default function CryptoOnrampFlow() {
480509
setResponse(null);
481510
setIsLinkUser(false);
482511
setCustomerId(null);
483-
setPaymentDisplayData(null);
512+
setCurrentPaymentDisplayData(null);
484513
setCryptoPaymentToken(null);
485514
setAuthToken(null);
486515
setWalletAddress(null);
@@ -528,7 +557,7 @@ export default function CryptoOnrampFlow() {
528557
<OnrampResponseStatusSection
529558
response={response}
530559
customerId={customerId}
531-
paymentDisplayData={paymentDisplayData}
560+
paymentDisplayData={currentPaymentDisplayData}
532561
cryptoPaymentToken={cryptoPaymentToken}
533562
authToken={authToken}
534563
walletAddress={walletAddress}
@@ -554,6 +583,13 @@ export default function CryptoOnrampFlow() {
554583

555584
{isLinkUser === true && customerId != null && (
556585
<>
586+
<View style={{ paddingHorizontal: 16 }}>
587+
<Button
588+
title="Display Static Payment Data"
589+
onPress={showPaymentData}
590+
variant="primary"
591+
/>
592+
</View>
557593
<AttachKycInfoSection
558594
userInfo={userInfo}
559595
setUserInfo={setUserInfo}

ios/StripeOnrampSdk.mm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ - (instancetype)init
116116
[StripeSdkImpl.shared onrampAuthorize:linkAuthIntentId resolver:resolve rejecter:reject];
117117
}
118118

119+
RCT_EXPORT_METHOD(getCryptoTokenDisplayData:(nonnull NSDictionary *)token
120+
resolve:(nonnull RCTPromiseResolveBlock)resolve
121+
reject:(nonnull RCTPromiseRejectBlock)reject)
122+
{
123+
[StripeSdkImpl.shared getCryptoTokenDisplayData:token resolver:resolve rejecter:reject];
124+
}
125+
119126
RCT_EXPORT_METHOD(initialise:(nonnull NSDictionary *)params
120127
resolve:(nonnull RCTPromiseResolveBlock)resolve
121128
reject:(nonnull RCTPromiseRejectBlock)reject)

ios/StripeSdkImpl.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import PassKit
22
@_spi(DashboardOnly) @_spi(STP) import Stripe
33
@_spi(EmbeddedPaymentElementPrivateBeta) import StripePaymentSheet
4+
@_spi(AppearanceAPIAdditionsPreview) import StripePaymentSheet
45
@_spi(STP) import StripePaymentSheet
56
#if canImport(StripeCryptoOnramp)
67
@_spi(STP) import StripeCryptoOnramp
@@ -1556,6 +1557,49 @@ public class StripeSdkImpl: NSObject, UIAdaptivePresentationControllerDelegate {
15561557
}
15571558
}
15581559

1560+
@objc(getCryptoTokenDisplayData:resolver:rejecter:)
1561+
public func getCryptoTokenDisplayData(
1562+
token: NSDictionary,
1563+
resolver resolve: @escaping RCTPromiseResolveBlock,
1564+
rejecter reject: @escaping RCTPromiseRejectBlock
1565+
) -> Void {
1566+
let label = STPPaymentMethodType.link.displayName
1567+
1568+
if let cardDetails = token["card"] as? [String: Any] {
1569+
let brand = cardDetails["brand"] as? String ?? ""
1570+
let funding = cardDetails["funding"] as? String ?? ""
1571+
let last4 = cardDetails["last4"] as? String ?? ""
1572+
1573+
let cardBrand = STPCard.brand(from: brand)
1574+
let icon = STPImageLibrary.cardBrandImage(for: cardBrand)
1575+
let brandName = STPCardBrandUtilities.stringFrom(cardBrand)
1576+
1577+
let mappedFunding = STPCardFundingType(funding)
1578+
let formattedBrandName = String(format: mappedFunding.displayNameWithBrand, brandName ?? "")
1579+
let sublabel = "\(formattedBrandName) •••• \(last4)"
1580+
1581+
let result = PaymentMethodDisplayData(icon: icon, label: label, sublabel: sublabel)
1582+
let displayData = Mappers.paymentMethodDisplayDataToMap(result)
1583+
1584+
resolve(["displayData": displayData])
1585+
} else if let bankDetails = token["us_bank_account"] as? [String: Any] {
1586+
let bankName = bankDetails["bank_name"] as? String ?? ""
1587+
let last4 = bankDetails["last4"] as? String ?? ""
1588+
1589+
let iconCode = PaymentSheetImageLibrary.bankIconCode(for: bankName)
1590+
let icon = PaymentSheetImageLibrary.bankIcon(for: iconCode, iconStyle: .filled)
1591+
let sublabel = "\(bankName) •••• \(last4)"
1592+
1593+
let result = PaymentMethodDisplayData(icon: icon, label: label, sublabel: sublabel)
1594+
let displayData = Mappers.paymentMethodDisplayDataToMap(result)
1595+
1596+
resolve(["displayData": displayData])
1597+
} else {
1598+
let errorResult = Errors.createError(ErrorType.Unknown, "'type' parameter not unknown.")
1599+
resolve(["error": errorResult["error"]!])
1600+
}
1601+
}
1602+
15591603
/// Checks for a `publishableKey`. Calls the resolve block with an error when one doesn’t exist.
15601604
/// - Parameter resolve: The resolve block that is called with an error if no `publishableKey` is found.
15611605
/// - Returns: `true` if a `publishableKey` was found. `false` otherwise.
@@ -1651,6 +1695,11 @@ public class StripeSdkImpl: NSObject, UIAdaptivePresentationControllerDelegate {
16511695
resolveWithCryptoOnrampNotAvailableError(resolve)
16521696
}
16531697

1698+
@objc(getCryptoTokenDisplayData:)
1699+
public func getCryptoTokenDisplayData(token: NSDictionary) -> [String: Any]? {
1700+
return nil
1701+
}
1702+
16541703
private func resolveWithCryptoOnrampNotAvailableError(_ resolver: @escaping RCTPromiseResolveBlock) {
16551704
resolver(Errors.createError(ErrorType.Failed, "StripeCryptoOnramp is not available. To enable, add the 'stripe-react-native/Onramp' subspec to your Podfile."))
16561705
}
@@ -1770,3 +1819,23 @@ extension FinancialConnectionsSheet.Configuration {
17701819
self.init(style: style)
17711820
}
17721821
}
1822+
1823+
private extension STPCardFundingType {
1824+
var displayNameWithBrand: String {
1825+
switch self {
1826+
case .credit: String.Localized.Funding.credit
1827+
case .debit: String.Localized.Funding.debit
1828+
case .prepaid: String.Localized.Funding.prepaid
1829+
case .other: String.Localized.Funding.default
1830+
}
1831+
}
1832+
1833+
init(_ typeString: String) {
1834+
self = switch typeString {
1835+
case "debit": .debit
1836+
case "credit": .credit
1837+
case "prepaid": .prepaid
1838+
default: .other
1839+
}
1840+
}
1841+
}

src/hooks/useOnramp.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { EventSubscription } from 'react-native';
22
import NativeOnrampSdk from '../specs/NativeOnrampSdkModule';
3-
import type { Onramp, OnrampError, StripeError } from '../types';
3+
import { Onramp, OnrampError, StripeError } from '../types';
44
import type { PlatformPay } from '../types';
55
import { useCallback } from 'react';
66
import { addOnrampListener } from '../events';
7+
import { CryptoPaymentToken } from '../types/Onramp';
78

89
let onCheckoutClientSecretRequestedSubscription: EventSubscription | null =
910
null;
@@ -140,6 +141,15 @@ export function useOnramp() {
140141
[]
141142
);
142143

144+
const _getCryptoTokenDisplayData = useCallback(
145+
async (
146+
token: CryptoPaymentToken
147+
): Promise<Onramp.PaymentDisplayDataResult> => {
148+
return NativeOnrampSdk.getCryptoTokenDisplayData(token);
149+
},
150+
[]
151+
);
152+
143153
const _logOut = useCallback(async (): Promise<{
144154
error?: StripeError<OnrampError>;
145155
}> => {
@@ -259,6 +269,15 @@ export function useOnramp() {
259269
*/
260270
authorize: _authorize,
261271

272+
/**
273+
* Retrieves display data (icon, label, sublabel) for the given payment method details.
274+
* Suitable for rendering in the UI to summarize the selected payment method.
275+
*
276+
* @param token The token containing payment method details (card or bank account) to get display data for
277+
* @returns Promise that resolves to an object with displayData or error
278+
*/
279+
getCryptoTokenDisplayData: _getCryptoTokenDisplayData,
280+
262281
/**
263282
* Logs out the current user from their Link account.
264283
*

0 commit comments

Comments
 (0)