This commit is contained in:
Condi
2026-02-13 15:22:47 +08:00
parent dd42dc04ce
commit 1c49093a73
29 changed files with 8311 additions and 0 deletions

27
ios/RNBraintreeDropIn.m Normal file
View File

@@ -0,0 +1,27 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(RNBraintreeDropIn, NSObject)
RCT_EXTERN_METHOD(show:(NSDictionary *)options
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(fetchMostRecentPaymentMethod:(NSString *)clientToken
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(tokenizeCard:(NSString *)clientToken
cardInfo:(NSDictionary *)cardInfo
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(collectDeviceData:(NSString *)clientToken
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
+ (BOOL)requiresMainQueueSetup
{
return YES;
}
@end

394
ios/RNBraintreeDropIn.swift Normal file
View File

@@ -0,0 +1,394 @@
import Foundation
import UIKit
import PassKit
import BraintreeCore
import BraintreeDropIn
import BraintreeCard
import BraintreeDataCollector
import BraintreeApplePay
import BraintreeVenmo
@objc(RNBraintreeDropIn)
class RNBraintreeDropIn: NSObject {
private var dataCollector: BTDataCollector?
private var braintreeClient: BTAPIClient?
private var paymentRequest: PKPaymentRequest?
private var viewController: PKPaymentAuthorizationViewController?
private var deviceDataCollector: String = ""
private var currentResolve: RCTPromiseResolveBlock?
private var currentReject: RCTPromiseRejectBlock?
private var applePayAuthorized: Bool = false
override init() {
super.init()
}
@objc
static func requiresMainQueueSetup() -> Bool {
return true
}
@objc
func show(_ options: NSDictionary,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.currentResolve = resolve
self.currentReject = reject
self.applePayAuthorized = false
guard let clientToken = options["clientToken"] as? String else {
reject("NO_CLIENT_TOKEN", "You must provide a client token", nil)
return
}
// Setup color scheme
var colorScheme: BTDropInUICustomization.ColorScheme = .light
if let darkTheme = options["darkTheme"] as? Bool, darkTheme {
if #available(iOS 13.0, *) {
colorScheme = .dynamic
} else {
colorScheme = .dark
}
}
let uiCustomization = BTDropInUICustomization(colorScheme: colorScheme)
if let fontFamily = options["fontFamily"] as? String {
uiCustomization.fontFamily = fontFamily
}
if let boldFontFamily = options["boldFontFamily"] as? String {
uiCustomization.boldFontFamily = boldFontFamily
}
let request = BTDropInRequest()
request.uiCustomization = uiCustomization
// 3D Secure setup
if let threeDSecureOptions = options["threeDSecure"] as? NSDictionary {
guard let amount = threeDSecureOptions["amount"] as? NSNumber else {
reject("NO_3DS_AMOUNT", "You must provide an amount for 3D Secure", nil)
return
}
let threeDSecureRequest = BTThreeDSecureRequest()
threeDSecureRequest.amount = NSDecimalNumber(value: amount.doubleValue)
request.threeDSecureRequest = threeDSecureRequest
}
// Initialize API client and data collector
let apiClient = BTAPIClient(authorization: clientToken)!
self.dataCollector = BTDataCollector(apiClient: apiClient)
self.dataCollector?.collectDeviceData { [weak self] deviceData in
self?.deviceDataCollector = deviceData
}
// Vault manager
if let vaultManager = options["vaultManager"] as? Bool, vaultManager {
request.vaultManager = true
}
// Card disabled
if let cardDisabled = options["cardDisabled"] as? Bool, cardDisabled {
request.cardDisabled = true
}
// Apple Pay setup
if let applePay = options["applePay"] as? Bool, applePay {
self.setupApplePay(options: options, clientToken: clientToken, reject: reject)
// Don't disable Apple Pay in request
} else {
request.applePayDisabled = true
}
// Venmo setup
if let venmo = options["venmo"] as? Bool, venmo {
request.venmoDisabled = false
} else {
request.venmoDisabled = true
}
// PayPal setup
if let payPal = options["payPal"] as? Bool, !payPal {
request.paypalDisabled = true
}
// Initialize Drop-In controller
let dropIn = BTDropInController(authorization: clientToken, request: request) { [weak self] (controller, result, error) in
guard let self = self else { return }
controller.dismiss(animated: true) {
if let error = error {
self.currentReject?("DROP_IN_ERROR", error.localizedDescription, error)
} else if result?.isCanceled == true {
self.currentReject?("USER_CANCELLATION", "The user cancelled", nil)
} else if let result = result {
self.handleDropInResult(result, threeDSecureOptions: options["threeDSecure"] as? NSDictionary)
}
}
}
if let dropIn = dropIn {
guard let rootViewController = self.getRootViewController() else {
reject("NO_ROOT_VC", "Could not find root view controller", nil)
return
}
rootViewController.present(dropIn, animated: true, completion: nil)
} else {
reject("INVALID_CLIENT_TOKEN", "The client token seems invalid", nil)
}
}
}
private func setupApplePay(options: NSDictionary, clientToken: String, reject: @escaping RCTPromiseRejectBlock) {
guard let merchantIdentifier = options["merchantIdentifier"] as? String,
let countryCode = options["countryCode"] as? String,
let currencyCode = options["currencyCode"] as? String,
let merchantName = options["merchantName"] as? String,
let orderTotal = options["orderTotal"] as? NSNumber else {
reject("MISSING_OPTIONS", "Not all required Apple Pay options were provided", nil)
return
}
self.braintreeClient = BTAPIClient(authorization: clientToken)
let paymentRequest = PKPaymentRequest()
paymentRequest.merchantIdentifier = merchantIdentifier
paymentRequest.merchantCapabilities = .capability3DS
paymentRequest.countryCode = countryCode
paymentRequest.currencyCode = currencyCode
paymentRequest.supportedNetworks = [.amex, .visa, .masterCard, .discover, .chinaUnionPay]
let paymentSummaryItem = PKPaymentSummaryItem(
label: merchantName,
amount: NSDecimalNumber(value: orderTotal.doubleValue)
)
paymentRequest.paymentSummaryItems = [paymentSummaryItem]
self.paymentRequest = paymentRequest
if let vc = PKPaymentAuthorizationViewController(paymentRequest: paymentRequest) {
vc.delegate = self
self.viewController = vc
}
}
private func handleDropInResult(_ result: BTDropInResult, threeDSecureOptions: NSDictionary?) {
// Check for 3D Secure
if let threeDSecureOptions = threeDSecureOptions,
let cardNonce = result.paymentMethod as? BTCardNonce {
if let threeDSecureInfo = cardNonce.threeDSecureInfo {
if !threeDSecureInfo.liabilityShiftPossible && threeDSecureInfo.wasVerified {
self.currentReject?("3DSECURE_NOT_ABLE_TO_SHIFT_LIABILITY", "3D Secure liability cannot be shifted", nil)
return
} else if !threeDSecureInfo.liabilityShifted && threeDSecureInfo.wasVerified {
self.currentReject?("3DSECURE_LIABILITY_NOT_SHIFTED", "3D Secure liability was not shifted", nil)
return
}
}
}
// Check for Apple Pay
if result.paymentMethod == nil,
let paymentMethodType = result.paymentMethodType?.rawValue,
(16...18).contains(paymentMethodType) {
// Apple Pay flow
if let viewController = self.viewController,
let rootViewController = self.getRootViewController() {
rootViewController.present(viewController, animated: true, completion: nil)
}
return
}
// Check for Venmo
if let venmoNonce = result.paymentMethod as? BTVenmoAccountNonce {
let resultDict: [String: Any] = [
"nonce": venmoNonce.nonce,
"type": "Venmo",
"description": "Venmo \(venmoNonce.username)",
"isDefault": false,
"deviceData": self.deviceDataCollector
]
self.currentResolve?(resultDict)
return
}
// Default payment method handling
self.resolvePayment(result: result)
}
@objc
func fetchMostRecentPaymentMethod(_ clientToken: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
BTDropInResult.mostRecentPaymentMethod(forClientToken: clientToken) { [weak self] (result, error) in
if let error = error {
reject("FETCH_ERROR", error.localizedDescription, error)
} else if result?.isCanceled == true {
reject("USER_CANCELLATION", "The user cancelled", nil)
} else if let result = result {
self?.resolvePayment(result: result)
resolve(self?.buildResultDictionary(from: result))
} else {
resolve(nil)
}
}
}
@objc
func tokenizeCard(_ clientToken: String,
cardInfo: NSDictionary,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
let cvv = cardInfo["cvv"] as? String
let onlyCVV = cardInfo["onlyCVV"] as? Bool ?? false
if onlyCVV {
guard cvv != nil else {
reject("INVALID_CARD_INFO", "Please enter cvv", nil)
return
}
} else {
guard let _ = cardInfo["number"] as? String,
let _ = cardInfo["expirationMonth"] as? String,
let _ = cardInfo["expirationYear"] as? String,
cvv != nil,
let _ = cardInfo["postalCode"] as? String else {
reject("INVALID_CARD_INFO", "Invalid card info", nil)
return
}
}
let braintreeClient = BTAPIClient(authorization: clientToken)!
let cardClient = BTCardClient(apiClient: braintreeClient)
let card = BTCard()
if !onlyCVV {
card.number = cardInfo["number"] as? String
card.expirationMonth = cardInfo["expirationMonth"] as? String
card.expirationYear = cardInfo["expirationYear"] as? String
card.postalCode = cardInfo["postalCode"] as? String
}
card.cvv = cvv
cardClient.tokenize(card) { [weak self] (tokenizedCard, error) in
if let error = error {
reject("TOKENIZE_ERROR", "Error tokenizing card", error)
} else if let tokenizedCard = tokenizedCard {
var jsResult: [String: Any] = [
"nonce": tokenizedCard.nonce,
"type": "card"
]
let dataCollector = BTDataCollector(apiClient: braintreeClient)
dataCollector.collectDeviceData { deviceData in
if !deviceData.isEmpty {
jsResult["deviceData"] = deviceData
resolve(jsResult)
} else {
reject("DEVICE_DATA_ERROR", "Failed to collect device data", nil)
}
}
}
}
}
@objc
func collectDeviceData(_ clientToken: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
let apiClient = BTAPIClient(authorization: clientToken)!
let dataCollector = BTDataCollector(apiClient: apiClient)
dataCollector.collectDeviceData { deviceData in
if !deviceData.isEmpty {
resolve(deviceData)
} else {
reject("DEVICE_DATA_ERROR", "Failed to collect device data", nil)
}
}
}
private func resolvePayment(result: BTDropInResult) {
guard let resolve = self.currentResolve else { return }
let resultDict = buildResultDictionary(from: result)
resolve(resultDict)
}
private func buildResultDictionary(from result: BTDropInResult) -> [String: Any]? {
guard let paymentMethod = result.paymentMethod else {
return nil
}
return [
"nonce": paymentMethod.nonce,
"type": paymentMethod.type,
"description": result.paymentDescription ?? "",
"isDefault": paymentMethod.isDefault,
"deviceData": self.deviceDataCollector
]
}
private func getRootViewController() -> UIViewController? {
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
return nil
}
var rootViewController = window.rootViewController
if let presented = rootViewController?.presentedViewController {
rootViewController = presented
}
return rootViewController
}
}
// MARK: - PKPaymentAuthorizationViewControllerDelegate
extension RNBraintreeDropIn: PKPaymentAuthorizationViewControllerDelegate {
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) {
guard let braintreeClient = self.braintreeClient else {
completion(PKPaymentAuthorizationResult(status: .failure, errors: nil))
return
}
let applePayClient = BTApplePayClient(apiClient: braintreeClient)
applePayClient.tokenize(payment) { [weak self] (tokenizedPayment, error) in
if let tokenizedPayment = tokenizedPayment {
completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
self?.applePayAuthorized = true
let result: [String: Any] = [
"nonce": tokenizedPayment.nonce,
"type": "Apple Pay",
"description": "Apple Pay \(tokenizedPayment.type ?? "")",
"isDefault": false,
"deviceData": self?.deviceDataCollector ?? ""
]
self?.currentResolve?(result)
} else {
completion(PKPaymentAuthorizationResult(status: .failure, errors: nil))
}
}
}
func paymentAuthorizationViewControllerDidFinish(_ controller: PKPaymentAuthorizationViewController) {
controller.dismiss(animated: true) { [weak self] in
if self?.applePayAuthorized == false {
self?.currentReject?("USER_CANCELLATION", "The user cancelled", nil)
}
}
}
}