diff --git a/.gitignore b/.gitignore index 8a35199d..ddbb826b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,8 @@ dist/ .vscode example/android/build/ example/.yalc -example/yalc.lock \ No newline at end of file +example/yalc.lock + +FabricExample/android/build/ +FabricExample/.yalc +FabricExample/yalc.lock \ No newline at end of file diff --git a/FabricExample/ios/Podfile.lock b/FabricExample/ios/Podfile.lock index 1a293fe6..50c0871b 100644 --- a/FabricExample/ios/Podfile.lock +++ b/FabricExample/ios/Podfile.lock @@ -7,7 +7,7 @@ PODS: - hermes-engine (0.74.1): - hermes-engine/Pre-built (= 0.74.1) - hermes-engine/Pre-built (0.74.1) - - Plaid (5.5.1) + - Plaid (6.0.0) - RCT-Folly (2024.01.01.00): - boost - DoubleConversion @@ -936,11 +936,11 @@ PODS: - React-Mapbuffer (0.74.1): - glog - React-debug - - react-native-plaid-link-sdk (11.10.2): + - react-native-plaid-link-sdk (12.0.0): - DoubleConversion - glog - hermes-engine - - Plaid (~> 5.5.1) + - Plaid (~> 6.0.0) - RCT-Folly (= 2024.01.01.00) - RCTRequired - RCTTypeSafety @@ -1515,7 +1515,7 @@ SPEC CHECKSUMS: fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 hermes-engine: 16b8530de1b383cdada1476cf52d1b52f0692cbc - Plaid: 276eb2892728a7b33a89f54aa0d62cf532af2c1d + Plaid: ae1b67f78e433a5465939fd093c6af6d024c38b2 RCT-Folly: 02617c592a293bd6d418e0a88ff4ee1f88329b47 RCTDeprecation: efb313d8126259e9294dc4ee0002f44a6f676aba RCTRequired: f49ea29cece52aee20db633ae7edc4b271435562 @@ -1540,7 +1540,7 @@ SPEC CHECKSUMS: React-jsitracing: 233d1a798fe0ff33b8e630b8f00f62c4a8115fbc React-logger: 7e7403a2b14c97f847d90763af76b84b152b6fce React-Mapbuffer: 11029dcd47c5c9e057a4092ab9c2a8d10a496a33 - react-native-plaid-link-sdk: aa721fc10b14926ee16f805083c2a61ad0c693a5 + react-native-plaid-link-sdk: 14d024322c284481ca74925c23c851faa218a126 react-native-safe-area-context: 7f54ad0a774de306ab790c70d9d950321e5c5449 React-nativeconfig: b0073a590774e8b35192fead188a36d1dca23dec React-NativeModulesApple: df46ff3e3de5b842b30b4ca8a6caae6d7c8ab09f diff --git a/FabricExample/package-lock.json b/FabricExample/package-lock.json index 5d888834..b958e429 100644 --- a/FabricExample/package-lock.json +++ b/FabricExample/package-lock.json @@ -16,7 +16,7 @@ "react": "18.2.0", "react-native": "0.74.1", "react-native-gesture-handler": "^2.16.2", - "react-native-plaid-link-sdk": "^11.10.3", + "react-native-plaid-link-sdk": "file:.yalc/react-native-plaid-link-sdk", "react-native-safe-area-context": "4.10.1", "react-native-screens": "3.31.1" }, @@ -41,6 +41,14 @@ "node": ">=18" } }, + ".yalc/react-native-plaid-link-sdk": { + "version": "12.0.0", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "license": "MIT", @@ -11577,13 +11585,8 @@ } }, "node_modules/react-native-plaid-link-sdk": { - "version": "11.10.3", - "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-11.10.3.tgz", - "integrity": "sha512-cklgvgs5p4yxIrP6vEgFegNEP9lSPasAcErS5pZVZs5oOw7VudW4GcHvYUabG6YO4vY2fq8EBKGiNDxCu97Pyw==", - "peerDependencies": { - "react": "*", - "react-native": "*" - } + "resolved": ".yalc/react-native-plaid-link-sdk", + "link": true }, "node_modules/react-native-safe-area-context": { "version": "4.10.1", diff --git a/FabricExample/package.json b/FabricExample/package.json index 0c758425..3074c025 100644 --- a/FabricExample/package.json +++ b/FabricExample/package.json @@ -18,7 +18,7 @@ "react": "18.2.0", "react-native": "0.74.1", "react-native-gesture-handler": "^2.16.2", - "react-native-plaid-link-sdk": "^11.10.3", + "react-native-plaid-link-sdk": "file:.yalc/react-native-plaid-link-sdk", "react-native-safe-area-context": "4.10.1", "react-native-screens": "3.31.1" }, diff --git a/FabricExample/src/Screens/PlaidLinkScreen.tsx b/FabricExample/src/Screens/PlaidLinkScreen.tsx index e2de3ca3..2f776e17 100644 --- a/FabricExample/src/Screens/PlaidLinkScreen.tsx +++ b/FabricExample/src/Screens/PlaidLinkScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {TextInput, Text, TouchableOpacity} from 'react-native'; +import {Platform, TextInput, Text, TouchableOpacity} from 'react-native'; import {styles} from '../Styles'; import { @@ -12,10 +12,14 @@ import { usePlaidEmitter, LinkIOSPresentationStyle, LinkTokenConfiguration, + FinanceKitError, + create, + open, + syncFinanceKit, + submit, + SubmissionData, } from 'react-native-plaid-link-sdk'; -import {create, open} from 'react-native-plaid-link-sdk/dist/PlaidLink'; - function isValidString(str: string): boolean { if (str && str.trim() !== '') { return true; @@ -35,6 +39,12 @@ function createLinkTokenConfiguration( }; } +function createSubmissionData(phoneNumber: string): SubmissionData { + return { + phoneNumber: phoneNumber, + }; +} + function createLinkOpenProps(): LinkOpenProps { return { onSuccess: (success: LinkSuccess) => { @@ -53,7 +63,8 @@ function createLinkOpenProps(): LinkOpenProps { }; } -export function PlaidLinkScreen(): React.JSX.Element { +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function PlaidLinkScreen() { // Render using the link_token integration. Refer to the docs // https://plaid.com/docs/#create-link-token on how to create // a new link_token. @@ -67,6 +78,32 @@ export function PlaidLinkScreen(): React.JSX.Element { const [text, onChangeText] = React.useState(''); const [disabled, setDisabled] = React.useState(true); + const iOSVersionParts = String(Platform.Version).split('.'); + const [majorVersion, minorVersion] = + iOSVersionParts.length >= 2 ? iOSVersionParts : [null, null]; + + const financeKitText = () => { + if (majorVersion && minorVersion) { + const majorInt = parseInt(majorVersion, 10); + const minorInt = parseInt(minorVersion, 10); + + if (majorInt > 17) { + return Sync FinanceKit; + } else if (majorInt === 17 && minorInt >= 4) { + return Sync FinanceKit; + } else { + return ( + + FinanceKit not supported on this version of iOS + + ); + } + } else { + // Fallback return if majorVersion or minorVersion are not provided. + return Invalid iOS version; + } + }; + return ( <> Create Link + { + const submissionData = createSubmissionData('415-555-0015'); + submit(submissionData); + }}> + Submit Layer Phone Number + Open Link + { + const completionHandler = (error?: FinanceKitError) => { + if (error) { + console.error('Error:', error); + } else { + console.log('Sync completed successfully'); + } + }; + const requestAuthorizationIfNeeded = true; + syncFinanceKit(text, requestAuthorizationIfNeeded, completionHandler); + }}> + {financeKitText()} + ); -} +} \ No newline at end of file diff --git a/example/src/Screens/PlaidEmbeddedLinkScreen.tsx b/example/src/Screens/PlaidEmbeddedLinkScreen.tsx index 659106bb..836ba1cc 100644 --- a/example/src/Screens/PlaidEmbeddedLinkScreen.tsx +++ b/example/src/Screens/PlaidEmbeddedLinkScreen.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import {TextInput, Text, View} from 'react-native'; import {styles} from '../Styles'; import { - EmbeddedLinkView, + // EmbeddedLinkView, LinkIOSPresentationStyle, LinkEvent, LinkExit, @@ -57,7 +57,7 @@ export function PlaidEmbeddedLinkScreen() { placeholder="link-sandbox-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" placeholderTextColor={'#D3D3D3'} /> - + {/* */} ); } diff --git a/ios/Extensions.swift b/ios/Extensions.swift new file mode 100644 index 00000000..ea65bae3 --- /dev/null +++ b/ios/Extensions.swift @@ -0,0 +1,41 @@ +import Foundation +import LinkKit + +extension LinkKit.Plaid.CreateError { + var code: Int { + switch self { + case .configurationError(let nestedError): + return nestedError.code + @unknown default: + return -1 + } + } +} + +extension LinkKit.ConfigurationError { + var code: Int { + switch self { + case .malformedClientID: return 0 + case .missingAuthorization: return 1 + case .noProduct: return 2 + case .invalidOptionValue: return 3 + case .invalidOptionCombination: return 4 + case .invalidToken: return 5 + @unknown default: return -1 + } + } +} + +@available(iOS 17.4, *) +extension LinkKit.FinanceKitError { + var code: Int { + switch self { + case .invalidToken: return 0 + case .permissionError: return 1 + case .linkApiError: return 2 + case .permissionAccessError: return 3 + case .unknown: return 4 + @unknown default: return -1 + } + } +} diff --git a/ios/JSONHelper.swift b/ios/JSONHelper.swift new file mode 100644 index 00000000..03f167da --- /dev/null +++ b/ios/JSONHelper.swift @@ -0,0 +1,394 @@ +import Foundation +import LinkKit + +struct JSONHelper { + static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter + }() + + // MARK: Success + + static func dictionaryFromSuccess(success: LinkSuccess) -> [String: Any] { + let metadata = success.metadata + + return [ + "publicToken": success.publicToken, + "metadata": [ + "linkSessionId": metadata.linkSessionID, + "institution": dictionaryFromInstitution(institution: metadata.institution), + "accounts": accountsDictionariesFromAccounts(accounts: metadata.accounts), + "metadataJson": metadata.metadataJSON ?? "", + ], + ] + } + + static func accountsDictionariesFromAccounts(accounts: [Account]) -> [[String: Any]] { + var results = [[String: Any]]() + + for account in accounts { + let accountDictionary = dictionaryFromAccount(account: account) + results.append(accountDictionary) + } + + return results + } + + static func dictionaryFromAccount(account: Account) -> [String: Any] { + return [ + "id": account.id, + "name": account.name, + "mask": account.mask ?? "", + "subtype": subtypeNameForAccountSubtype(accountSubtype: account.subtype), + "type": typeNameForAccountSubtype(accountSubtype: account.subtype), + "verificationStatus": stringForVerificationStatus(verificationStatus: account.verificationStatus), + ] + } + + static func stringForVerificationStatus(verificationStatus: VerificationStatus?) -> String { + guard let verificationStatus = verificationStatus else { return "" } + switch verificationStatus { + case .pendingAutomaticVerification: return "pending_automatic_verification" + case .pendingManualVerification: return "pending_manual_verification" + case .manuallyVerified: return "manually_verified" + case .unknown(let unknownString): return unknownString + @unknown default: return "unknown" + } + } + + static func typeNameForAccountSubtype(accountSubtype: AccountSubtype) -> String { + switch accountSubtype { + case .unknown(let type, subtype: _): return type + case .other: return "other" + case .credit: return "credit" + case .loan: return "loan" + case .depository: return "depository" + case .investment: return "investment" + @unknown default: return "unknown" + } + } + + static func subtypeNameForAccountSubtype(accountSubtype: AccountSubtype) -> String { + switch accountSubtype { + case .unknown(_, let subtype): return subtype + case .other(let other): + switch other { + case .all: return "all" + case .other: return "other" + case .unknown(let unknown): return unknown + @unknown default: return "unknown" + } + case .credit(let credit): + switch credit { + case .all: return "all" + case .creditCard: return "credit card" + case .paypal: return "paypal" + case .unknown(let unknown): return unknown + @unknown default: return "unknown" + + } + case .loan(let loan): + switch loan { + case .all: return "all" + case .auto: return "auto" + case .business: return "business" + case .commercial: return "commercial" + case .construction: return "construction" + case .consumer: return "consumer" + case .homeEquity: return "home equity" + case .lineOfCredit: return "line of credit" + case .loan: return "loan" + case .mortgage: return "mortgage" + case .overdraft: return "overdraft" + case .student: return "student" + case .unknown(let unknown): return unknown + @unknown default: return "unknown" + } + + case .depository(let depository): + switch depository { + case .all: return "all" + case .cashManagement: return "cash management" + case .cd: return "cd" + case .checking: return "checking" + case .ebt: return "ebt" + case .hsa: return "hsa" + case .moneyMarket: return "money market" + case .paypal: return "paypal" + case .prepaid: return "prepaid" + case .savings: return "savings" + case .unknown(let unknown): return unknown + @unknown default: return "unknown" + } + + case .investment(let investment): + switch investment { + case .all: return "all" + case .brokerage: return "brokerage" + case .cashIsa: return "cash isa" + case .educationSavingsAccount: return "education savings account" + case .fixedAnnuity: return "fixed annuity" + case .gic: return "gic" + case .healthReimbursementArrangement: return "health reimbursement arrangement" + case .hsa: return "hsa" + case .investment401a: return "401a" + case .investment401k: return "401k" + case .investment403B: return "403b" + case .investment457b: return "457b" + case .investment529: return "529" + case .ira: return "ira" + case .isa: return "isa" + case .keogh: return "keogh" + case .lif: return "lif" + case .lira: return "lira" + case .lrif: return "lrif" + case .lrsp: return "lrsp" + case .mutualFund: return "mutual fund" + case .nonTaxableBrokerageAccount: return "non-taxable brokerage account" + case .pension: return "pension" + case .plan: return "plan" + case .prif: return "prif" + case .profitSharingPlan: return "profit sharing plan" + case .rdsp: return "rdsp" + case .resp: return "resp" + case .retirement: return "retirement" + case .rlif: return "rlif" + case .roth: return "roth" + case .roth401k: return "roth 401k" + case .rrif: return "rrif" + case .rrsp: return "rrsp" + case .sarsep: return "sarsep" + case .sepIra: return "sep ira" + case .simpleIra: return "simple ira" + case .sipp: return "sipp" + case .stockPlan: return "stock plan" + case .tfsa: return "tfsa" + case .thriftSavingsPlan: return "thrift savings plan" + case .trust: return "trust" + case .ugma: return "ugma" + case .unknown(let unknown): return unknown + case .utma: return "utma" + case .variableAnnuity: return "variable annuity" + @unknown default: return "unknown" + } + @unknown default: return "uknown" + } + } + + static func dictionaryFromInstitution(institution: Institution?) -> [String: Any] { + return [ + "name": institution?.name ?? "", + "id": institution?.id ?? "", + ] + } + + // MARK: Exit + + static func dictionaryFromExit(exit: LinkExit) -> [String: Any] { + let metadata = exit.metadata + return [ + "error": dictionaryFromError(error: exit.error), + "metadata": [ + "status": stringForExitStatus(exitStatus: metadata.status), + "institution": dictionaryFromInstitution(institution: metadata.institution), + "requestId": metadata.requestID ?? "", + "linkSessionId": metadata.linkSessionID ?? "", + "metadataJson": metadata.metadataJSON ?? "", + ], + ] + } + + static func stringForExitStatus(exitStatus: ExitStatus?) -> String { + guard let exitStatus = exitStatus else { return "" } + + switch exitStatus { + case .requiresQuestions: + return "requires_questions" + case .requiresSelections: + return "requires_selections" + case .requiresCode: + return "requires_code" + case .chooseDevice: + return "choose_device" + case .requiresCredentials: + return "requires_credentials" + case .institutionNotFound: + return "institution_not_found" + case .requiresAccountSelection: + return "requires_account_selection" + case .continueToThirdParty: + return "continue_to_third_party" + case .unknown(let unknown): return unknown + @unknown default: return "unknown" + } + } + + static func dictionaryFromError(error: ExitError?) -> [String: String] { + return [ + "errorType": errorTypeStringFromError(error: error), + "errorCode": errorCodeStringFromError(error: error), + "errorMessage": errorMessageFromError(error: error), + // errorDisplayMessage is the deprecated name for displayMessage, both have to be populated + // until errorDisplayMessage is fully removed to avoid breaking the API + "errorDisplayMessage": errorDisplayMessageFromError(error: error), + "displayMessage": errorDisplayMessageFromError(error: error), + ] + } + + static func errorTypeStringFromError(error: ExitError?) -> String { + guard let error = error else { return "" } + switch error.errorCode { + case .apiError: + return "API_ERROR" + case .authError: + return "AUTH_ERROR" + case .assetReportError: + return "ASSET_REPORT_ERROR" + case .internal: + return "INTERNAL" + case .institutionError: + return "INSTITUTION_ERROR" + case .itemError: + return "ITEM_ERROR" + case .invalidInput: + return "INVALID_INPUT" + case .invalidRequest: + return "INVALID_REQUEST" + case .rateLimitExceeded: + return "RATE_LIMIT_EXCEEDED" + case .unknown: + return "UNKNOWN" + @unknown default: + return "UNKNOWN" + } + } + + static func errorTypeStringFromError(error: ExitErrorCode?) -> String { + guard let error = error else { return "" } + switch error { + case .apiError: + return "API_ERROR" + case .authError: + return "AUTH_ERROR" + case .assetReportError: + return "ASSET_REPORT_ERROR" + case .internal: + return "INTERNAL" + case .institutionError: + return "INSTITUTION_ERROR" + case .itemError: + return "ITEM_ERROR" + case .invalidInput: + return "INVALID_INPUT" + case .invalidRequest: + return "INVALID_REQUEST" + case .rateLimitExceeded: + return "RATE_LIMIT_EXCEEDED" + case .unknown: + return "UNKNOWN" + @unknown default: + return "UNKNOWN" + } + } + + static func errorCodeStringFromError(error: ExitError?) -> String { + guard let error = error else { return "" } + + switch error.errorCode { + case .apiError(let apiError): + return apiError.description + case .authError(let authError): + return authError.description + case .assetReportError(let assetReportError): + return assetReportError.description + case .internal(let internalError): + return internalError.description + case .institutionError(let institutionError): + return institutionError.description + case .itemError(let itemError): + return itemError.description + case .invalidInput(let invalidInputError): + return invalidInputError.description + case .invalidRequest(let invalidRequestError): + return invalidRequestError.description + case .rateLimitExceeded(let rateLimitExceededError): + return rateLimitExceededError.description + case .unknown(let type, _): + return type + @unknown default: + return "Unknown" + } + } + + static func errorCodeStringFromError(error: ExitErrorCode?) -> String { + guard let error = error else { return "" } + + switch error { + case .apiError(let apiError): + return apiError.description + case .authError(let authError): + return authError.description + case .assetReportError(let assetReportError): + return assetReportError.description + case .internal(let internalError): + return internalError.description + case .institutionError(let institutionError): + return institutionError.description + case .itemError(let itemError): + return itemError.description + case .invalidInput(let invalidInputError): + return invalidInputError.description + case .invalidRequest(let invalidRequestError): + return invalidRequestError.description + case .rateLimitExceeded(let rateLimitExceededError): + return rateLimitExceededError.description + case .unknown(let type, _): + return type + @unknown default: + return "Unknown" + } + } + + static func errorMessageFromError(error: ExitError?) -> String { + guard let error = error else { return "" } + return error.errorMessage + } + + static func errorDisplayMessageFromError(error: ExitError?) -> String { + guard let error = error else { return "" } + return error.displayMessage ?? "" + } + + // MARK: Event + + static func dictionaryFromEvent(_ event: LinkEvent) -> [String: Any] { + return [ + "eventName": event.eventName.description, + "metadata": dictionaryFromEventMetadata(event.metadata), + ] + } + + static func dictionaryFromEventMetadata(_ metadata: EventMetadata) -> [String: Any] { + return [ + "errorType": errorTypeStringFromError(error: metadata.errorCode), + "errorCode": errorCodeStringFromError(error: metadata.errorCode), + "errorMessage": metadata.errorMessage ?? "", + "exitStatus": stringForExitStatus(exitStatus: metadata.exitStatus), + "institutionId": metadata.institutionID ?? "", + "institutionName": metadata.institutionName ?? "", + "institutionSearchQuery": metadata.institutionSearchQuery ?? "", + "accountNumberMask": metadata.accountNumberMask ?? "", + "isUpdateMode": metadata.isUpdateMode ?? "", + "matchReason": metadata.matchReason ?? "", + "routingNumber": metadata.routingNumber ?? "", + "selection": metadata.selection ?? "", + "linkSessionId": metadata.linkSessionID, + "mfaType": metadata.mfaType?.description ?? "", + "requestId": metadata.requestID ?? "", + "timestamp": dateFormatter.string(from: metadata.timestamp), + "viewName": metadata.viewName?.description ?? "", + "metadata_json": metadata.metadataJSON ?? "", + ] + } +} diff --git a/ios/LayerSubmissionData.swift b/ios/LayerSubmissionData.swift new file mode 100644 index 00000000..8e4a468d --- /dev/null +++ b/ios/LayerSubmissionData.swift @@ -0,0 +1,6 @@ +import Foundation +import LinkKit + +struct LayerSubmissionData: SubmissionData { + let phoneNumber: String? +} diff --git a/ios/PLKEmbeddedView.m b/ios/PLKEmbeddedView.m deleted file mode 100644 index 01d3adfd..00000000 --- a/ios/PLKEmbeddedView.m +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(PLKEmbeddedViewManager, RCTViewManager) -RCT_EXPORT_VIEW_PROPERTY(token, NSString) -RCT_EXPORT_VIEW_PROPERTY(iOSPresentationStyle, NSString) -RCT_EXPORT_VIEW_PROPERTY(onEmbeddedEvent, RCTDirectEventBlock) -@end diff --git a/ios/PLKEmbeddedView.swift b/ios/PLKEmbeddedView.swift deleted file mode 100644 index 141d2b36..00000000 --- a/ios/PLKEmbeddedView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import LinkKit -import UIKit - -@objc public final class PLKEmbeddedView: UIView { - - // Properties exposed to React Native. - - @objc public var iOSPresentationStyle: String = "" { - didSet { - createNativeEmbeddedView() - } - } - - @objc public var token: String = "" { - didSet { - createNativeEmbeddedView() - } - } - - @objc public var onEmbeddedEvent: RCTDirectEventBlock? - - // MARK: Private - - private var linkHandler: Handler? - private let embeddedEventName: String = "embeddedEventName" - - private func makeHandler() throws -> Handler { - var config = LinkTokenConfiguration( - token: token, - onSuccess: { [weak self] success in - guard let self = self else { return } - - let plkLinkSuccess = success.toObjC - var dictionary = RNLinksdk.dictionary(from: plkLinkSuccess) ?? [:] - dictionary[self.embeddedEventName] = "onSuccess" - self.onEmbeddedEvent?(dictionary) - } - ) - - config.onEvent = { [weak self] event in - guard let self = self else { return } - - let plkLinkEvent = event.toObjC - var dictionary = RNLinksdk.dictionary(from: plkLinkEvent) ?? [:] - dictionary[self.embeddedEventName] = "onEvent" - self.onEmbeddedEvent?(dictionary) - } - - config.onExit = { [weak self] exit in - guard let self = self else { return } - - let plkLinkExit = exit.toObjC - var dictionary = RNLinksdk.dictionary(from: plkLinkExit) ?? [:] - dictionary[self.embeddedEventName] = "onExit" - self.onEmbeddedEvent?(dictionary) - } - - let handlerCreationResult = Plaid.create(config) - - switch handlerCreationResult { - case .failure(let error): - throw (error) - case .success(let handler): - return handler - } - } - - private func makeEmbeddedView(rctViewController: UIViewController, handler: Handler) -> UIView { - self.linkHandler = handler - - let presentationMethod: PresentationMethod - - if iOSPresentationStyle.uppercased() == "FULL_SCREEN" { - presentationMethod = .custom({ viewController in - viewController.modalPresentationStyle = .overFullScreen - viewController.modalTransitionStyle = .coverVertical - - rctViewController.present(viewController, animated: true) - }) - } else { - presentationMethod = .viewController(rctViewController) - } - - return handler.createEmbeddedView(presentUsing: presentationMethod) - } - - private func createNativeEmbeddedView() { - guard let rctViewController = RCTPresentedViewController() else { return } - guard !token.isEmpty, !iOSPresentationStyle.isEmpty, linkHandler == nil else { return } - - do { - let handler = try makeHandler() - let embeddedView = makeEmbeddedView(rctViewController: rctViewController, handler: handler) - setup(embeddedView: embeddedView) - } catch { - let dict: [String: Any] = [ - embeddedEventName: "onExit", - "error": "\(error)", - ] - - onEmbeddedEvent?(dict) - } - } - - private func setup(embeddedView: UIView) { - embeddedView.translatesAutoresizingMaskIntoConstraints = false - addSubview(embeddedView) - - NSLayoutConstraint.activate([ - embeddedView.topAnchor.constraint(equalTo: topAnchor), - embeddedView.leadingAnchor.constraint(equalTo: leadingAnchor), - embeddedView.trailingAnchor.constraint(equalTo: trailingAnchor), - embeddedView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } -} diff --git a/ios/PLKEmbeddedViewComponentView.h b/ios/PLKEmbeddedViewComponentView.h deleted file mode 100644 index 9cf089aa..00000000 --- a/ios/PLKEmbeddedViewComponentView.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifdef RCT_NEW_ARCH_ENABLED - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -/** - * This file is required for compatibility with React Native's New Architecture (Fabric Renderer). - * - * - PLKEmbeddedViewComponentView extends `RCTViewComponentView` to define a custom native view - * that works with the Fabric rendering system, improving UI performance and concurrency. - * - The `#ifdef RCT_NEW_ARCH_ENABLED` directive ensures this code is only compiled when the - * New Architecture is enabled, avoiding compatibility issues with older architectures. - * - Custom native views like this are essential when integrating UIKit-based components into - * React Native apps under the New Architecture. - * - `RCTUIManager` handles the interaction between the native view and the React Native bridge, - * enabling updates and commands from the JavaScript side. - * - * Ref - https://github.com/reactwg/react-native-new-architecture/blob/main/docs/backwards-compat-turbo-modules.md - */ -@interface PLKEmbeddedViewComponentView : RCTViewComponentView -@end - -NS_ASSUME_NONNULL_END - -#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/PLKEmbeddedViewComponentView.mm b/ios/PLKEmbeddedViewComponentView.mm deleted file mode 100644 index 155deda7..00000000 --- a/ios/PLKEmbeddedViewComponentView.mm +++ /dev/null @@ -1,91 +0,0 @@ -#ifdef RCT_NEW_ARCH_ENABLED - -#import "PLKEmbeddedViewComponentView.h" -#import "PLKFabricHelpers.h" - -#import -#import -#import - -#import -#import -#import -#import - -using namespace facebook::react; - -@implementation PLKEmbeddedViewComponentView { - PLKEmbeddedView *_view; -} - -// Needed because of this: https://github.com/facebook/react-native/pull/37274 -+ (void)load -{ - [super load]; -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - [self prepareView]; - } - - return self; -} - -- (void)prepareView -{ - _view = [[PLKEmbeddedView alloc] init]; - - __weak __typeof__(self) weakSelf = self; - - [_view setOnEmbeddedEvent:^(NSDictionary* event) { - __typeof__(self) strongSelf = weakSelf; - - if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) { - std::dynamic_pointer_cast(strongSelf->_eventEmitter)->onEmbeddedEvent({ - .embeddedEventName = RCTStringFromNSString(event[@"embeddedEventName"]), - .eventName = RCTStringFromNSString(event[@"eventName"]), - .error = PLKConvertIdToFollyDynamic(event[@"error"]), - .publicToken = RCTStringFromNSString(event[@"publicToken"]), - .metadata = PLKConvertIdToFollyDynamic(event[@"metadata"]), - }); - } - }]; - self.contentView = _view; -} - -#pragma mark - RCTComponentViewProtocol - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - - -- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps -{ - const auto &newProps = static_cast(*props); - _view.token = RCTNSStringFromStringNilIfEmpty(newProps.token); - _view.iOSPresentationStyle = RCTNSStringFromStringNilIfEmpty(newProps.token); - - [super updateProps:props oldProps:oldProps]; -} - -- (void)prepareForRecycle -{ - [super prepareForRecycle]; - [self prepareView]; -} - - -@end - -Class PLKEmbeddedViewCls(void) -{ - return PLKEmbeddedViewComponentView.class; -} - -#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/PLKEmbeddedViewManager.swift b/ios/PLKEmbeddedViewManager.swift deleted file mode 100644 index 49e99d66..00000000 --- a/ios/PLKEmbeddedViewManager.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -@objc(PLKEmbeddedViewManager) -class PLKEmbeddedViewManager: RCTViewManager { - - override static func requiresMainQueueSetup() -> Bool { - return true - } - - override func view() -> UIView! { - return PLKEmbeddedView() - } - -} diff --git a/ios/PLKFabricHelpers.h b/ios/PLKFabricHelpers.h deleted file mode 100644 index c432a18f..00000000 --- a/ios/PLKFabricHelpers.h +++ /dev/null @@ -1,78 +0,0 @@ -#import -#import -#import - -#if __has_include() -#import -#else -#ifdef USE_FRAMEWORKS -#import -#else -#ifdef RCT_NEW_ARCH_ENABLED -#import -#else -#import -#endif -#endif -#endif - -// copied from RCTFollyConvert -folly::dynamic PLKConvertIdToFollyDynamic(id json) -{ - if (json == nil || json == (id)kCFNull) { - return nullptr; - } else if ([json isKindOfClass:[NSNumber class]]) { - const char *objCType = [json objCType]; - switch (objCType[0]) { - // This is a c++ bool or C99 _Bool. On some platforms, BOOL is a bool. - case _C_BOOL: - return (bool)[json boolValue]; - case _C_CHR: - // On some platforms, objc BOOL is a signed char, but it - // might also be a small number. Use the same hack JSC uses - // to distinguish them: - // https://phabricator.intern.facebook.com/diffusion/FBS/browse/master/fbobjc/xplat/third-party/jsc/safari-600-1-4-17/JavaScriptCore/API/JSValue.mm;b8ee03916489f8b12143cd5c0bca546da5014fc9$901 - if ([json isKindOfClass:[@YES class]]) { - return (bool)[json boolValue]; - } else { - return [json longLongValue]; - } - case _C_UCHR: - case _C_SHT: - case _C_USHT: - case _C_INT: - case _C_UINT: - case _C_LNG: - case _C_ULNG: - case _C_LNG_LNG: - case _C_ULNG_LNG: - return [json longLongValue]; - - case _C_FLT: - case _C_DBL: - return [json doubleValue]; - - // default: - // fall through - } - } else if ([json isKindOfClass:[NSString class]]) { - NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; - return std::string(reinterpret_cast(data.bytes), data.length); - } else if ([json isKindOfClass:[NSArray class]]) { - folly::dynamic array = folly::dynamic::array; - for (id element in json) { - array.push_back(PLKConvertIdToFollyDynamic(element)); - } - return array; - } else if ([json isKindOfClass:[NSDictionary class]]) { - __block folly::dynamic object = folly::dynamic::object(); - - [json enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, __unused BOOL *stop) { - object.insert(PLKConvertIdToFollyDynamic(key), PLKConvertIdToFollyDynamic(value)); - }]; - - return object; - } - - return nil; -} diff --git a/ios/RNLinksdk-Bridging-Header.h b/ios/RNLinksdk-Bridging-Header.h index 45d7beea..51f60dac 100644 --- a/ios/RNLinksdk-Bridging-Header.h +++ b/ios/RNLinksdk-Bridging-Header.h @@ -1,8 +1,7 @@ -#import -#import -#import #import +#import #import -#import -#import "RNLinksdk.h" +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif \ No newline at end of file diff --git a/ios/RNLinksdk.h b/ios/RNLinksdk.h deleted file mode 100644 index 035b91ce..00000000 --- a/ios/RNLinksdk.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifdef RCT_NEW_ARCH_ENABLED -#import -#endif -#import -#import "RCTEventEmitter.h" - -#import - -@interface RNLinksdk : RCTEventEmitter -#ifdef RCT_NEW_ARCH_ENABLED - -#else - -#endif - -+ (NSDictionary *)dictionaryFromSuccess:(PLKLinkSuccess *)success; -+ (NSDictionary *)dictionaryFromEvent:(PLKLinkEvent *)event; -+ (NSDictionary *)dictionaryFromExit:(PLKLinkExit *)exit; -@end diff --git a/ios/RNLinksdk.mm b/ios/RNLinksdk.mm index d057f2d0..59896f8e 100644 --- a/ios/RNLinksdk.mm +++ b/ios/RNLinksdk.mm @@ -1,707 +1,51 @@ -#import "RNLinksdk.h" -#import "RNPlaidHelper.h" - -#import -#import -#import -#import - -static NSString* const kRNLinkKitOnEventEvent = @"onEvent"; -static NSString* const kRNLinkKitEventErrorKey = @"error"; -static NSString* const kRNLinkKitEventNameKey = @"event"; -static NSString* const kRNLinkKitEventMetadataKey = @"metadata"; -static NSString* const kRNLinkKitVersionConstant = @"version"; - -@interface RNLinksdk () -@property (nonatomic, strong) id linkHandler; -@property (nonatomic, strong) UIViewController* presentingViewController; -@property (nonatomic, strong) RCTResponseSenderBlock successCallback; -@property (nonatomic, strong) RCTResponseSenderBlock exitCallback; -@property (nonatomic, assign) BOOL hasObservers; -@property (nonatomic, copy) NSString *institutionID; -@property (nonatomic, nullable, strong) NSError *creationError; -@end - - -@implementation RNLinksdk - -RCT_EXPORT_MODULE(); - -+ (NSString*)sdkVersion { - return @"12.0.0"; // SDK_VERSION -} - -+ (NSString*)objCBridgeVersion { - return @"2.0.0"; -} - -+ (BOOL)requiresMainQueueSetup -{ - // Because LinkKit relies on UIKit. - return YES; -} - -- (dispatch_queue_t)methodQueue -{ - return dispatch_get_main_queue(); -} - -- (NSArray *)supportedEvents -{ - return @[kRNLinkKitOnEventEvent]; -} - -- (NSDictionary *)constantsToExport { - return @{ - kRNLinkKitVersionConstant: [NSString stringWithFormat:@"%s+%.0f", LinkKitVersionString, LinkKitVersionNumber], - }; -} - -- (void)startObserving { - self.hasObservers = YES; - [super startObserving]; -} - -- (void)stopObserving { - [super stopObserving]; - self.hasObservers = NO; -} - -RCT_EXPORT_METHOD(create:(NSString*)token noLoadingState:(BOOL)noLoadingState) { - __weak RNLinksdk *weakSelf = self; - - void (^onSuccess)(PLKLinkSuccess *) = ^(PLKLinkSuccess *success) { - RNLinksdk *strongSelf = weakSelf; - - if (strongSelf.successCallback) { - NSDictionary *jsMetadata = [RNLinksdk dictionaryFromSuccess:success]; - strongSelf.successCallback(@[jsMetadata]); - strongSelf.successCallback = nil; - } - }; - - void (^onExit)(PLKLinkExit *) = ^(PLKLinkExit *exit) { - RNLinksdk *strongSelf = weakSelf; - - if (strongSelf.exitCallback) { - NSDictionary *exitMetadata = [RNLinksdk dictionaryFromExit:exit]; - if (exit.error) { - strongSelf.exitCallback(@[exitMetadata[@"error"], exitMetadata]); - } else { - strongSelf.exitCallback(@[[NSNull null], exitMetadata]); - } - strongSelf.exitCallback = nil; - strongSelf.linkHandler = nil; - } - }; - - void (^onEvent)(PLKLinkEvent *) = ^(PLKLinkEvent *event) { - RNLinksdk *strongSelf = weakSelf; - if (strongSelf.hasObservers) { - NSDictionary *eventDictionary = [RNLinksdk dictionaryFromEvent:event]; - [strongSelf sendEventWithName:kRNLinkKitOnEventEvent - body:eventDictionary]; - - // If this is the HANDOFF event. - if (event.eventName.value == PLKEventNameValueHandoff) { - // If we have dismissed Link. - if (strongSelf.presentingViewController == nil) { - // Deallocate the handler it's no longer needed. - strongSelf.linkHandler = nil; - } - } - } - }; - - PLKLinkTokenConfiguration *config = [PLKLinkTokenConfiguration createWithToken:token onSuccess:onSuccess]; - config.onEvent = onEvent; - config.onExit = onExit; - config.noLoadingState = noLoadingState; - - NSError *creationError = nil; - self.linkHandler = [RNPlaidHelper createWithLinkTokenConfiguration:config error:&creationError]; - self.creationError = creationError; -} - -RCT_EXPORT_METHOD(open:(BOOL)fullScreen onSuccess:(RCTResponseSenderBlock)onSuccess onExit:(RCTResponseSenderBlock)onExit) { - if (self.linkHandler) { - self.successCallback = onSuccess; - self.exitCallback = onExit; - self.presentingViewController = RCTPresentedViewController(); - - // Some link flows do not need to present UI, so track if presentation happened so dismissal isn't - // unnecessarily invoked. - __block bool didPresent = NO; - - __weak RNLinksdk *weakSelf = self; - void(^presentationHandler)(UIViewController *) = ^(UIViewController *linkViewController) { - - if (fullScreen) { - [linkViewController setModalPresentationStyle:UIModalPresentationOverFullScreen]; - [linkViewController setModalTransitionStyle:UIModalTransitionStyleCoverVertical]; - } - - [weakSelf.presentingViewController presentViewController:linkViewController animated:YES completion:nil]; - didPresent = YES; - }; - void(^dismissalHandler)(UIViewController *) = ^(UIViewController *linkViewController) { - if (didPresent) { - [weakSelf dismiss]; - didPresent = NO; - } - }; - [self.linkHandler openWithPresentationHandler:presentationHandler dismissalHandler:dismissalHandler]; - } else { - NSString *errorMessage = self.creationError ? self.creationError.userInfo[@"message"] : @"Create was not called."; - NSString *errorCode = self.creationError ? [@(self.creationError.code) stringValue] : @"-1"; - - NSDictionary *linkExit = @{ - @"displayMessage": errorMessage, - @"errorCode": errorCode, - @"errorType": @"creation error", - @"errorMessage": errorMessage, - @"errorDisplayMessage": errorMessage, - @"errorJson": [NSNull null], - @"metadata": @{ - @"linkSessionId": [NSNull null], - @"institution": [NSNull null], - @"status": [NSNull null], - @"requestId": [NSNull null], - @"metadataJson": [NSNull null], - }, - }; - - onExit(@[linkExit]); - } -} - -RCT_EXPORT_METHOD(dismiss) { - [self.presentingViewController dismissViewControllerAnimated:YES - completion:nil]; - self.presentingViewController = nil; -} - -RCT_EXPORT_METHOD(syncFinanceKit:(NSString *)token +#import + +/// This interface exposes native functions for React Native, compatible with both the old architecture (Bridge) +/// and the new architecture (TurboModules & Fabric). These methods are implemented in `RNLinksdk.swift` +/// and provide functionality to interact with the Link SDK. +@interface RCT_EXTERN_MODULE(RNLinksdk, NSObject) + +// Creates a Link handler with a specified token. +// The handler initializes the Link SDK experience and prepares it for subsequent operations. +// +// Parameters: +// - token: A unique string used to authenticate and initialize the Link SDK. +// - noLoadingState: A boolean indicating whether to suppress the loading state during initialization. +RCT_EXTERN_METHOD(create:(NSString *)token noLoadingState:(BOOL)noLoadingState) + +// Opens the Link experience after the handler has been created. +// This function is used to present the Link UI to the user. +// +// Parameters: +// - fullScreen: A boolean indicating whether the Link UI should be displayed in full-screen mode. +// - onSuccess: A callback triggered when the Link flow is successfully completed. +// - onExit: A callback triggered when the user exits the Link flow without completing it. +RCT_EXTERN_METHOD(open:(BOOL)fullScreen + onSuccess:(RCTResponseSenderBlock)onSuccess + onExit:(RCTResponseSenderBlock)onExit) + +// Dismisses the currently displayed Link UI. +// Use this function to programmatically close the Link experience if necessary. +RCT_EXTERN_METHOD(dismiss) + +// Submits a user's phone number for an eligibility check for Layer services. +// This function checks if the provided phone number is eligible for additional features or services. +// +// Parameters: +// - phoneNumber: The user's phone number to be submitted for eligibility verification. +RCT_EXTERN_METHOD(submit:(NSString *)phoneNumber) + +// Syncs transactions from the user's Apple Card using FinanceKit. +// This function fetches recent Apple Card transactions and updates the user's linked accounts. +// +// Parameters: +// - token: A string used to authenticate and authorize the sync operation. +// - requestAuthorizationIfNeeded: A boolean indicating whether to request user authorization if it's not already granted. +// - onSuccess: A callback triggered when the sync operation completes successfully. +// - onError: A callback triggered when the sync operation fails due to an error. +RCT_EXTERN_METHOD(syncFinanceKit:(NSString *)token requestAuthorizationIfNeeded:(BOOL)requestAuthorizationIfNeeded onSuccess:(RCTResponseSenderBlock)onSuccess - onError:(RCTResponseSenderBlock)onError) { - - [RNPlaidHelper syncFinanceKit:token - requestAuthorizationIfNeeded:requestAuthorizationIfNeeded - onSuccess:^{ - onSuccess(@[]); - } - onError:^(NSError *error) { - - NSDictionary *financeKitError = @{ - @"type": [NSNumber numberWithInteger: error.code], - @"message": error.localizedDescription - }; - - onError(@[financeKitError]); - } - ]; -} - - -RCT_EXPORT_METHOD(submit:(NSString * _Nullable)phoneNumber) { - if (self.linkHandler) { - PLKSubmissionData *submissionData = [[PLKSubmissionData alloc] init]; - submissionData.phoneNumber = phoneNumber; - [self.linkHandler submit: submissionData]; - } -} - -#pragma mark - Bridging - -+ (PLKEnvironment)environmentFromString:(NSString *)string { - if ([string isEqualToString:@"production"]) { - return PLKEnvironmentProduction; - } - - if ([string isEqualToString:@"sandbox"]) { - return PLKEnvironmentSandbox; - } - - if ([string isEqualToString:@"development"]) { - return PLKEnvironmentDevelopment; - } - - // Default to Development - NSLog(@"Unexpected environment string value: %@. Expected one of: production, sandbox, or development.", string); - return PLKEnvironmentDevelopment; -} - -+ (NSDictionary *)dictionaryFromSuccess:(PLKLinkSuccess *)success { - PLKSuccessMetadata *metadata = success.metadata; - - return @{ - @"publicToken": success.publicToken ?: @"", - @"metadata": @{ - @"linkSessionId": metadata.linkSessionID ?: @"", - @"institution": [self dictionaryFromInstitution:metadata.institution] ?: @"", - @"accounts": [self accountsDictionariesFromAccounts:metadata.accounts] ?: @"", - @"metadataJson": metadata.metadataJSON ?: @"", - }, - }; -} - -+ (NSArray *)accountsDictionariesFromAccounts:(NSArray *)accounts { - NSMutableArray *results = [NSMutableArray arrayWithCapacity:accounts.count]; - - for (PLKAccount *account in accounts) { - NSDictionary *accountDictionary = [self dictionaryFromAccount:account]; - [results addObject:accountDictionary]; - } - return [results copy]; -} - -+ (NSDictionary *)dictionaryFromAccount:(PLKAccount *)account { - return @{ - @"id": account.ID ?: @"", - @"name": account.name ?: @"", - @"mask": account.mask ?: @"", - @"subtype": [self subtypeNameForAccountSubtype:account.subtype] ?: @"", - @"type": [self typeNameForAccountSubtype:account.subtype] ?: @"", - @"verificationStatus": [self stringForVerificationStatus:account.verificationStatus] ?: @"", - }; -} - -+ (NSString *)stringForVerificationStatus:(PLKVerificationStatus *)verificationStatus { - if (!verificationStatus) { - return @""; - } - - if (verificationStatus.unknownStringValue) { - return verificationStatus.unknownStringValue; - } - - switch (verificationStatus.value) { - case PLKVerificationStatusValueNone: - return @""; - case PLKVerificationStatusValuePendingAutomaticVerification: - return @"pending_automatic_verification"; - case PLKVerificationStatusValuePendingManualVerification: - return @"pending_manual_verification"; - case PLKVerificationStatusValueManuallyVerified: - return @"manually_verified"; - } - - return @"unknown"; -} - -+ (NSString *)typeNameForAccountSubtype:(id)accountSubtype { - if ([accountSubtype isKindOfClass:[PLKAccountSubtypeUnknown class]]) { - return ((PLKAccountSubtypeUnknown *)accountSubtype).rawStringValue; - } else if ([accountSubtype isKindOfClass:[PLKAccountSubtypeOther class]]) { - return @"other"; - } else if ([accountSubtype isKindOfClass:[PLKAccountSubtypeCredit class]]) { - return @"credit"; - } else if ([accountSubtype isKindOfClass:[PLKAccountSubtypeLoan class]]) { - return @"loan"; - } else if ([accountSubtype isKindOfClass:[PLKAccountSubtypeDepository class]]) { - return @"depository"; - } else if ([accountSubtype isKindOfClass:[PLKAccountSubtypeInvestment class]]) { - return @"investment"; - } - return @"unknown"; -} - -+ (NSString *)subtypeNameForAccountSubtype:(id)accountSubtype { - if ([accountSubtype isKindOfClass:[PLKAccountSubtypeUnknown class]]) { - return ((PLKAccountSubtypeUnknown *)accountSubtype).rawSubtypeStringValue; - } - return accountSubtype.rawStringValue; -} - -+ (NSDictionary *)dictionaryFromInstitution:(PLKInstitution *)institution { - return @{ - @"name": institution.name ?: @"", - @"id": institution.ID ?: @"", - }; -} - -+ (NSDictionary *)dictionaryFromError:(PLKExitError *)error { - return @{ - @"errorType": [self errorTypeStringFromError:error] ?: @"", - @"errorCode": [self errorCodeStringFromError:error] ?: @"", - @"errorMessage": [self errorMessageFromError:error] ?: @"", - // errorDisplayMessage is the deprecated name for displayMessage, both have to be populated - // until errorDisplayMessage is fully removed to avoid breaking the API - @"errorDisplayMessage": [self errorDisplayMessageFromError:error] ?: @"", - @"displayMessage": [self errorDisplayMessageFromError:error] ?: @"", - }; -} - -+ (NSDictionary *)dictionaryFromEvent:(PLKLinkEvent *)event { - PLKEventMetadata *metadata = event.eventMetadata; - - return @{ - @"eventName": [self stringForEventName:event.eventName] ?: @"", - @"metadata": @{ - @"errorType": [self errorTypeStringFromError:metadata.error] ?: @"", - @"errorCode": [self errorCodeStringFromError:metadata.error] ?: @"", - @"errorMessage": [self errorMessageFromError:metadata.error] ?: @"", - @"exitStatus": [self stringForExitStatus:metadata.exitStatus] ?: @"", - @"institutionId": metadata.institutionID ?: @"", - @"institutionName": metadata.institutionName ?: @"", - @"institutionSearchQuery": metadata.institutionSearchQuery ?: @"", - @"accountNumberMask": metadata.accountNumberMask ?: @"", - @"isUpdateMode": metadata.isUpdateMode ?: @"", - @"matchReason": metadata.matchReason ?: @"", - @"routingNumber": metadata.routingNumber ?: @"", - @"selection": metadata.selection ?: @"", - @"linkSessionId": metadata.linkSessionID ?: @"", - @"mfaType": [self stringForMfaType:metadata.mfaType] ?: @"", - @"requestId": metadata.requestID ?: @"", - @"timestamp": [self iso8601StringFromDate:metadata.timestamp] ?: @"", - @"viewName": [self stringForViewName:metadata.viewName] ?: @"", - @"metadata_json": metadata.metadataJSON ?: @"", - }, - }; -} - -+ (NSString *)errorDisplayMessageFromError:(PLKExitError *)error { - return error.userInfo[kPLKExitErrorDisplayMessageKey] ?: @""; -} - -+ (NSString *)errorTypeStringFromError:(PLKExitError *)error { - NSString *errorDomain = error.domain; - if (!error || !errorDomain) { - return @""; - } - - NSString *normalizedErrorDomain = errorDomain; - - return @{ - kPLKExitErrorInvalidRequestDomain: @"INVALID_REQUEST", - kPLKExitErrorInvalidInputDomain: @"INVALID_INPUT", - kPLKExitErrorInstitutionErrorDomain: @"INSTITUTION_ERROR", - kPLKExitErrorRateLimitExceededDomain: @"RATE_LIMIT_EXCEEDED", - kPLKExitErrorApiDomain: @"API_ERROR", - kPLKExitErrorItemDomain: @"ITEM_ERROR", - kPLKExitErrorAuthDomain: @"AUTH_ERROR", - kPLKExitErrorAssetReportDomain: @"ASSET_REPORT_ERROR", - kPLKExitErrorInternalDomain: @"INTERNAL", - kPLKExitErrorUnknownDomain: error.userInfo[kPLKExitErrorUnknownTypeKey] ?: @"UNKNOWN", - }[normalizedErrorDomain] ?: @"UNKNOWN"; -} - -+ (NSString *)errorCodeStringFromError:(PLKExitError *)error { - NSString *errorDomain = error.domain; - - if (!error || !errorDomain) { - return @""; - } - return error.userInfo[kPLKExitErrorCodeKey]; -} - -+ (NSString *)errorMessageFromError:(PLKExitError *)error { - return error.userInfo[kPLKExitErrorMessageKey] ?: @""; -} - -+ (NSString *)stringForEventName:(PLKEventName *)eventName { - if (!eventName) { - return @""; - } - - if (eventName.unknownStringValue) { - return eventName.unknownStringValue; - } - - switch (eventName.value) { - case PLKEventNameValueNone: - return @""; - case PLKEventNameValueBankIncomeInsightsCompleted: - return @"BANK_INCOME_INSIGHTS_COMPLETED"; - case PLKEventNameValueCloseOAuth: - return @"CLOSE_OAUTH"; - case PLKEventNameValueError: - return @"ERROR"; - case PLKEventNameValueExit: - return @"EXIT"; - case PLKEventNameValueFailOAuth: - return @"FAIL_OAUTH"; - case PLKEventNameValueHandoff: - return @"HANDOFF"; - case PLKEventNameValueIdentityVerificationStartStep: - return @"IDENTITY_VERIFICATION_START_STEP"; - case PLKEventNameValueIdentityVerificationPassStep: - return @"IDENTITY_VERIFICATION_PASS_STEP"; - case PLKEventNameValueIdentityVerificationFailStep: - return @"IDENTITY_VERIFICATION_FAIL_STEP"; - case PLKEventNameValueIdentityVerificationPendingReviewStep: - return @"IDENTITY_VERIFICATION_PENDING_REVIEW_STEP"; - case PLKEventNameValueIdentityVerificationCreateSession: - return @"IDENTITY_VERIFICATION_CREATE_SESSION"; - case PLKEventNameValueIdentityVerificationResumeSession: - return @"IDENTITY_VERIFICATION_RESUME_SESSION"; - case PLKEventNameValueIdentityVerificationPassSession: - return @"IDENTITY_VERIFICATION_PASS_SESSION"; - case PLKEventNameValueIdentityVerificationFailSession: - return @"IDENTITY_VERIFICATION_FAIL_SESSION"; - case PLKEventNameValueIdentityVerificationOpenUI: - return @"IDENTITY_VERIFICATION_OPEN_UI"; - case PLKEventNameValueIdentityVerificationResumeUI: - return @"IDENTITY_VERIFICATION_RESUME_UI"; - case PLKEventNameValueIdentityVerificationCloseUI: - return @"IDENTITY_VERIFICATION_CLOSE_UI"; - case PLKEventNameValueMatchedSelectInstitution: - return @"MATCHED_SELECT_INSTITUTION"; - case PLKEventNameValueMatchedSelectVerifyMethod: - return @"MATCHED_SELECT_VERIFY_METHOD"; - case PLKEventNameValueOpen: - return @"OPEN"; - case PLKEventNameValueOpenMyPlaid: - return @"OPEN_MY_PLAID"; - case PLKEventNameValueOpenOAuth: - return @"OPEN_OAUTH"; - case PLKEventNameValueProfileEligibilityCheckReady: - return @"PROFILE_ELIGIBILITY_CHECK_READY"; - case PLKEventNameValueProfileEligibilityCheckError: - return @"PROFILE_ELIGIBILITY_CHECK_ERROR"; - case PLKEventNameValueSearchInstitution: - return @"SEARCH_INSTITUTION"; - case PLKEventNameValueSelectDegradedInstitution: - return @"SELECT_DEGRADED_INSTITUTION"; - case PLKEventNameValueSelectDownInstitution: - return @"SELECT_DOWN_INSTITUTION"; - case PLKEventNameValueSelectInstitution: - return @"SELECT_INSTITUTION"; - case PLKEventNameValueSubmitCredentials: - return @"SUBMIT_CREDENTIALS"; - case PLKEventNameValueSubmitMFA: - return @"SUBMIT_MFA"; - case PLKEventNameValueTransitionView: - return @"TRANSITION_VIEW"; - case PLKEventNameValueIdentityVerificationPendingReviewSession: - return @"IDENTITY_VERIFICATION_PENDING_REVIEW_SESSION"; - case PLKEventNameValueSelectFilteredInstitution: - return @"SELECT_FILTERED_INSTITUTION"; - case PLKEventNameValueSelectBrand: - return @"SELECT_BRAND"; - case PLKEventNameValueSelectAuthType: - return @"SELECT_AUTH_TYPE"; - case PLKEventNameValueSubmitAccountNumber: - return @"SUBMIT_ACCOUNT_NUMBER"; - case PLKEventNameValueSubmitDocuments: - return @"SUBMIT_DOCUMENTS"; - case PLKEventNameValueSubmitDocumentsSuccess: - return @"SUBMIT_DOCUMENTS_SUCCESS"; - case PLKEventNameValueSubmitDocumentsError: - return @"SUBMIT_DOCUMENTS_ERROR"; - case PLKEventNameValueSubmitRoutingNumber: - return @"SUBMIT_ROUTING_NUMBER"; - case PLKEventNameValueViewDataTypes: - return @"VIEW_DATA_TYPES"; - case PLKEventNameValueSubmitPhone: - return @"SUBMIT_PHONE"; - case PLKEventNameValueSkipSubmitPhone: - return @"SKIP_SUBMIT_PHONE"; - case PLKEventNameValueVerifyPhone: - return @"VERIFY_PHONE"; - case PLKEventNameValueConnectNewInstitution: - return @"CONNECT_NEW_INSTITUTION"; - case PLKEventNameValueSubmitOTP: - return @"SUBMIT_OTP"; - case PLKEventNameValueLayerReady: - return @"LAYER_READY"; - case PLKEventNameValueLayerNotAvailable: - return @"LAYER_NOT_AVAILABLE"; - case PLKEventNameValueSubmitEmail: - return @"SUBMIT_EMAIL"; - case PLKEventNameValueSkipSubmitEmail: - return @"SKIP_SUBMIT_EMAIL"; - case PLKEventNameValueRememberMeEnabled: - return @"REMEMBER_ME_ENABLED"; - case PLKEventNameValueRememberMeDisabled: - return @"REMEMBER_ME_DISABLED"; - case PLKEventNameValueRememberMeHoldout: - return @"REMEMBER_ME_HOLDOUT"; - case PLKEventNameValueSelectSavedInstitution: - return @"SELECT_SAVED_INSTITUTION"; - case PLKEventNameValueSelectSavedAccount: - return @"SELECT_SAVED_ACCOUNT"; - case PLKEventNameValueAutoSelectSavedInstitution: - return @"AUTO_SELECT_SAVED_INSTITUTION"; - case PLKEventNameValuePlaidCheckPane: - return @"PLAID_CHECK_PANE"; - } - return @"unknown"; -} - -+ (NSString *)iso8601StringFromDate:(NSDate *)date { - static NSISO8601DateFormatter *dateFormatter = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - dateFormatter = [[NSISO8601DateFormatter alloc] init]; - dateFormatter.formatOptions |= NSISO8601DateFormatWithFractionalSeconds; - }); - return [dateFormatter stringFromDate:date]; -} - -+ (NSString *)stringForExitStatus:(PLKExitStatus *)exitStatus { - if (!exitStatus) { - return @""; - } - - if (exitStatus.unknownStringValue) { - return exitStatus.unknownStringValue; - } - - switch (exitStatus.value) { - case PLKExitStatusValueNone: - return @""; - case PLKExitStatusValueRequiresQuestions: - return @"requires_questions"; - case PLKExitStatusValueRequiresSelections: - return @"requires_selections"; - case PLKExitStatusValueRequiresCode: - return @"requires_code"; - case PLKExitStatusValueChooseDevice: - return @"choose_device"; - case PLKExitStatusValueRequiresCredentials: - return @"requires_credentials"; - case PLKExitStatusValueInstitutionNotFound: - return @"institution_not_found"; - case PLKExitStatusValueRequiresAccountSelection: - return @"requires_account_selection"; - case PLKExitStatusValueContinueToThridParty: - return @"continue_to_third_party"; - } - return @"unknown"; -} - -+ (NSString *)stringForMfaType:(PLKMFAType)mfaType { - switch (mfaType) { - case PLKMFATypeNone: - return @""; - case PLKMFATypeCode: - return @"code"; - case PLKMFATypeDevice: - return @"device"; - case PLKMFATypeQuestions: - return @"questions"; - case PLKMFATypeSelections: - return @"selections"; - } - - return @"unknown"; -} - -+ (NSString *)stringForViewName:(PLKViewName *)viewName { - if (!viewName) { - return @""; - } - - if (viewName.unknownStringValue) { - return viewName.unknownStringValue; - } - - switch (viewName.value) { - case PLKViewNameValueNone: - return @""; - case PLKViewNameValueConnected: - return @"CONNECTED"; - case PLKViewNameValueConsent: - return @"CONSENT"; - case PLKViewNameValueCredential: - return @"CREDENTIAL"; - case PLKViewNameValueError: - return @"ERROR"; - case PLKViewNameValueExit: - return @"EXIT"; - case PLKViewNameValueLoading: - return @"LOADING"; - case PLKViewNameValueMatchedConsent: - return @"MATCHED_CONSENT"; - case PLKViewNameValueMatchedCredential: - return @"MATCHED_CREDENTIAL"; - case PLKViewNameValueMatchedMFA: - return @"MATCHED_MFA"; - case PLKViewNameValueMFA: - return @"MFA"; - case PLKViewNameValueNumbers: - return @"NUMBERS"; - case PLKViewNameValueRecaptcha: - return @"RECAPTCHA"; - case PLKViewNameValueSelectAccount: - return @"SELECT_ACCOUNT"; - case PLKViewNameValueSelectInstitution: - return @"SELECT_INSTITUTION"; - case PLKViewNameValueUploadDocuments: - return @"UPLOAD_DOCUMENTS"; - case PLKViewNameValueSubmitDocuments: - return @"SUBMIT_DOCUMENTS"; - case PLKViewNameValueSubmitDocumentsSuccess: - return @"SUBMIT_DOCUMENTS_SUCCESS"; - case PLKViewNameValueSubmitDocumentsError: - return @"SUBMIT_DOCUMENTS_ERROR"; - case PLKViewNameValueOauth: - return @"OAUTH"; - case PLKViewNameValueAcceptTOS: - return @"ACCEPT_TOS"; - case PLKViewNameValueDocumentaryVerification: - return @"DOCUMENTARY_VERIFICATION"; - case PLKViewNameValueKYCCheck: - return @"KYC_CHECK"; - case PLKViewNameValueSelfieCheck: - return @"SELFIE_CHECK"; - case PLKViewNameValueRiskCheck: - return @"RISK_CHECK"; - case PLKViewNameValueScreening: - return @"SCREENING"; - case PLKViewNameValueVerifySMS: - return @"VERIFY_SMS"; - case PLKViewNameValueDataTransparency: - return @"DATA_TRANSPARENCY"; - case PLKViewNameValueDataTransparencyConsent: - return @"DATA_TRANSPARENCY_CONSENT"; - case PLKViewNameValueSelectAuthType: - return @"SELECT_AUTH_TYPE"; - case PLKViewNameValueSelectBrand: - return @"SELECT_BRAND"; - case PLKViewNameValueNumbersSelectInstitution: - return @"NUMBERS_SELECT_INSTITUTION"; - case PLKViewNameValueSubmitPhone: - return @"SUBMIT_PHONE"; - case PLKViewNameValueVerifyPhone: - return @"VERIFY_PHONE"; - case PLKViewNameValueSelectSavedInstitution: - return @"SELECT_SAVED_INSTITUTION"; - case PLKViewNameValueSelectSavedAccount: - return @"SELECT_SAVED_ACCOUNT"; - case PLKViewNameValueProfileDataReview: - return @"PROFILE_DATA_REVIEW"; - case PLKViewNameValueSubmitEmail: - return @"SUBMIT_EMAIL"; - case PLKViewNameValueVerifyEmail: - return @"VERIFY_EMAIL"; - } - - return @"unknown"; -} - -+ (NSDictionary *)dictionaryFromExit:(PLKLinkExit *)exit { - PLKExitMetadata *metadata = exit.metadata; - return @{ - @"error": [self dictionaryFromError:exit.error] ?: @{}, - @"metadata": @{ - @"status": [self stringForExitStatus:metadata.status] ?: @"", - @"institution": [self dictionaryFromInstitution:metadata.institution] ?: @"", - @"requestId": metadata.requestID ?: @"", - @"linkSessionId": metadata.linkSessionID ?: @"", - @"metadataJson": metadata.metadataJSON ?: @"", - }, - }; -} - -#if RCT_NEW_ARCH_ENABLED -- (std::shared_ptr)getTurboModule: - (const facebook::react::ObjCTurboModule::InitParams &)params -{ - return std::make_shared(params); -} -#endif + onError:(RCTResponseSenderBlock)onError) @end diff --git a/ios/RNLinksdk.swift b/ios/RNLinksdk.swift new file mode 100644 index 00000000..7a82c5f2 --- /dev/null +++ b/ios/RNLinksdk.swift @@ -0,0 +1,216 @@ +import LinkKit +import React + +@objc(RNLinksdk) +class RNLinksdk: RCTEventEmitter { + + // MARK: Internal Constants + + let linkKitOnEventNotification = "onEvent" + let linkKitVersionConstant = "version" + + // MARK: RCTEventEmitter + + override func supportedEvents() -> [String] { + return [linkKitOnEventNotification] + } + + override func startObserving() { + self.hasOnEventObserver = true + super.startObserving() + } + + override func stopObserving() { + self.hasOnEventObserver = false + super.stopObserving() + } + + override class func requiresMainQueueSetup() -> Bool { + // LinkKit relies on UIKit. + return true + } + + class func sdkVersion() -> String { + // SDK_VERSION + return "11.13.2" + } + + class func objCBridgeVersion() -> String { + return "3.0.0" + } + + override func constantsToExport() -> [AnyHashable: Any]! { + [ + linkKitVersionConstant: String(format: "%s+%.0f", Plaid.version, LinkKitBuild) + ] + } + + // MARK: LinkKit API + + @objc(create:noLoadingState:) + func create(token: String, noLoadingState: Bool) { + + let onSuccess: OnSuccessHandler = { [weak self] success in + guard let self = self else { return } + + if let successCallback = self.successCallback { + let successsMetadata = JSONHelper.dictionaryFromSuccess(success: success) + successCallback([successsMetadata]) + self.successCallback = nil + } + } + + let onExit: OnExitHandler = { [weak self] exit in + guard let self = self else { return } + + if let exitCallback = self.exitCallback { + let exitMetadata = JSONHelper.dictionaryFromExit(exit: exit) + if exit.error != nil { + exitCallback([exitMetadata["error"] ?? [:], exitMetadata]) + } else { + exitCallback([NSNull(), exitMetadata]) + } + } + } + + let onEvent: OnEventHandler = { [weak self] event in + guard let self = self, self.hasOnEventObserver else { return } + + let eventDictionary = JSONHelper.dictionaryFromEvent(event) + sendLinkEvent(eventDictionary: eventDictionary) + } + + var configuration = LinkTokenConfiguration(token: token, onSuccess: onSuccess) + configuration.onExit = onExit + configuration.onEvent = onEvent + + let result = Plaid.create(configuration) + switch result { + case .success(let handler): + self.handler = handler + case .failure(let error): + self.createError = error + } + } + + @objc(open:onSuccess:onExit:) + func open(fullScreen: Bool, onSuccess: @escaping RCTResponseSenderBlock, onExit: @escaping RCTResponseSenderBlock) { + DispatchQueue.main.async { + guard let handler = self.handler else { + let errorMessage = self.createError?.localizedDescription ?? "Create was not called." + let errorCode = self.createError.map { String($0.code) } ?? "-1" + + let linkExit: [String: Any] = [ + "displayMessage": errorMessage, + "errorCode": errorCode, + "errorType": "creation error", + "errorMessage": errorMessage, + "errorDisplayMessage": errorMessage, + "errorJson": NSNull(), + "metadata": [ + "linkSessionId": NSNull(), + "institution": NSNull(), + "status": NSNull(), + "requestId": NSNull(), + "metadataJson": NSNull(), + ], + ] + + onExit([linkExit]) + return + } + + self.successCallback = onSuccess + self.exitCallback = onExit + self.presentingViewController = RCTPresentedViewController() + + // Track if presentation happened to avoid unnecessary dismissal invocation + var didPresent = false + + // Capture weak reference to self to avoid retain cycles + weak var weakSelf = self + + // Define the presentation handler + let presentationHandler: (UIViewController) -> Void = { linkViewController in + if fullScreen { + linkViewController.modalPresentationStyle = .overFullScreen + linkViewController.modalTransitionStyle = .coverVertical + } + + weakSelf?.presentingViewController?.present(linkViewController, animated: true, completion: nil) + didPresent = true + } + + // Define the dismissal handler + let dismissalHandler: (UIViewController) -> Void = { linkViewController in + if didPresent { + didPresent = false + + DispatchQueue.main.async { + weakSelf?.presentingViewController?.dismiss(animated: true, completion: nil) + } + } + } + + // Open with the defined handlers + handler.open(presentUsing: .custom(presentationHandler, dismissalHandler)) + } + } + + @objc(dismiss) + func dismiss() { + DispatchQueue.main.async { + self.presentingViewController?.dismiss(animated: true) + } + } + + @objc(submit:) + func submit(phoneNumber: String) { + let submissionData = LayerSubmissionData(phoneNumber: phoneNumber) + handler?.submit(data: submissionData) + } + + @objc(syncFinanceKit:requestAuthorizationIfNeeded:onSuccess:onError:) + @available(iOS 17.4, *) + func syncFinanceKit( + token: String, + requestAuthorizationIfNeeded: Bool, + onSuccess: @escaping RCTResponseSenderBlock, + onError: @escaping RCTResponseSenderBlock + ) { + + Plaid.syncFinanceKit( + token: token, + requestAuthorizationIfNeeded: requestAuthorizationIfNeeded, + completion: { result in + switch result { + case .success: + onSuccess([]) + case .failure(let error): + let financeKitError: [String: Any] = [ + "type": error.code, + "message": error.localizedDescription, + ] + + onError([financeKitError]) + } + } + ) + } + + // MARK: Private + + private var successCallback: RCTResponseSenderBlock? + private var exitCallback: RCTResponseSenderBlock? + + private var handler: LinkKit.Handler? + private var createError: LinkKit.Plaid.CreateError? + + private var presentingViewController: UIViewController? + + private var hasOnEventObserver: Bool = false + + private func sendLinkEvent(eventDictionary: [String: Any]) { + sendEvent(withName: linkKitOnEventNotification, body: eventDictionary) + } +} diff --git a/ios/RNPlaidHelper.h b/ios/RNPlaidHelper.h deleted file mode 100644 index 82aa7ab6..00000000 --- a/ios/RNPlaidHelper.h +++ /dev/null @@ -1,11 +0,0 @@ -#import - -@interface RNPlaidHelper : NSObject - -+ (id _Nullable)createWithLinkTokenConfiguration:(PLKLinkTokenConfiguration * _Nonnull)linkTokenConfiguration error:(NSError * _Nullable * _Nullable)error; - -+ (void) syncFinanceKit:(NSString *_Nonnull)token - requestAuthorizationIfNeeded:(BOOL)requestAuthorizationIfNeeded - onSuccess:(void (^_Nonnull)(void))onSuccess - onError:(void (^_Nonnull)(NSError * _Nonnull error))onError; -@end diff --git a/ios/RNPlaidHelper.m b/ios/RNPlaidHelper.m deleted file mode 100644 index dc19513a..00000000 --- a/ios/RNPlaidHelper.m +++ /dev/null @@ -1,21 +0,0 @@ -#import "RNPlaidHelper.h" - -@implementation RNPlaidHelper - -+ (id _Nullable)createWithLinkTokenConfiguration:(PLKLinkTokenConfiguration * _Nonnull)linkTokenConfiguration error:(NSError * _Nullable * _Nullable)error -{ - return [PLKPlaid createWithLinkTokenConfiguration:linkTokenConfiguration error:error]; -} - -+ (void)syncFinanceKit:(NSString *)token requestAuthorizationIfNeeded:(BOOL)requestAuthorizationIfNeeded onSuccess:(void (^)(void))onSuccess onError:(void (^)(NSError * _Nonnull))onError { - if (@available(iOS 17.4, *)) { - [PLKPlaid syncFinanceKitWithToken:token requestAuthorizationIfNeeded:requestAuthorizationIfNeeded onSuccess:onSuccess onError:onError]; - } else { - NSError *error = [NSError errorWithDomain:@"com.plaid.financeKit" - code:1001 - userInfo:@{ NSLocalizedDescriptionKey: @"FinanceKit Requires iOS >= 17.4" }]; - onError(error); - } -} - -@end diff --git a/src/EmbeddedLink/EmbeddedLinkView.tsx b/src/EmbeddedLink/EmbeddedLinkView.tsx deleted file mode 100644 index 9c36a223..00000000 --- a/src/EmbeddedLink/EmbeddedLinkView.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -import { StyleProp, ViewStyle } from 'react-native'; -import NativeEmbeddedLinkView from './NativeEmbeddedLinkView'; -import { - LinkSuccessListener, - LinkSuccess, - LinkExitListener, - LinkExit, - LinkIOSPresentationStyle, - LinkOnEventListener, - LinkEvent, - LinkEventName, - LinkEventMetadata, - LinkError, - LinkExitMetadata, - LinkSuccessMetadata, -} from '../Types'; - -type EmbeddedLinkProps = { - token: string, - iOSPresentationStyle: LinkIOSPresentationStyle, - onEvent: LinkOnEventListener | undefined, - onSuccess: LinkSuccessListener, - onExit: LinkExitListener | undefined, - style: StyleProp | undefined, -} - -class EmbeddedEvent implements LinkEvent { - eventName: LinkEventName; - metadata: LinkEventMetadata; - - constructor(event: any) { - this.eventName = event.eventName - this.metadata = event.metadata - } -} - -class EmbeddedExit implements LinkExit { - error: LinkError | undefined; - metadata: LinkExitMetadata; - - constructor(event: any) { - this.error = event.error; - this.metadata = event.metadata; - } -} - -class EmbeddedSuccess implements LinkSuccess { - publicToken: string; - metadata: LinkSuccessMetadata; - - constructor(event: any) { - this.publicToken = event.publicToken; - this.metadata = event.metadata; - } -} - -export const EmbeddedLinkView: React.FC = (props) => { - - const {token, iOSPresentationStyle, onEvent, onSuccess, onExit, style} = props; - - const onEmbeddedEvent = (event: any) => { - - switch (event.nativeEvent.embeddedEventName) { - case 'onSuccess': { - if (!onSuccess) { return; } - const embeddedSuccess = new EmbeddedSuccess(event.nativeEvent); - onSuccess(embeddedSuccess); - break; - } - case 'onExit': { - if (!onExit) {return; } - const embeddedExit = new EmbeddedExit(event.nativeEvent); - onExit(embeddedExit); - break; - } - case 'onEvent': { - if (!onEvent) { return; } - const embeddedEvent = new EmbeddedEvent(event.nativeEvent); - onEvent(embeddedEvent); - break; - } - default: { - return; - } - } - } - - return -}; \ No newline at end of file diff --git a/src/EmbeddedLink/EmbeddedLinkView.web.tsx b/src/EmbeddedLink/EmbeddedLinkView.web.tsx deleted file mode 100644 index 7a716097..00000000 --- a/src/EmbeddedLink/EmbeddedLinkView.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// EmbeddedLinkView.web.tsx is a shim file which causes web bundlers to ignore the EmbeddedLinkView.tsx file -// which imports requireNativeComponent (causing a runtime error with react-native-web). -// Ref - https://github.com/plaid/react-native-plaid-link-sdk/issues/564 -import React from 'react'; -export const EmbeddedLinkView = () => null; diff --git a/src/EmbeddedLink/NativeEmbeddedLinkView.tsx b/src/EmbeddedLink/NativeEmbeddedLinkView.tsx deleted file mode 100644 index 3b37d481..00000000 --- a/src/EmbeddedLink/NativeEmbeddedLinkView.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import NativeEmbeddedLinkView from '../fabric/EmbeddedLinkViewNativeComponent'; - -export default NativeEmbeddedLinkView; diff --git a/src/PlaidLink.tsx b/src/PlaidLink.tsx index b2ef8ac7..d0b4ac29 100644 --- a/src/PlaidLink.tsx +++ b/src/PlaidLink.tsx @@ -16,91 +16,121 @@ import { import RNLinksdkAndroid from './fabric/NativePlaidLinkModuleAndroid'; import RNLinksdkiOS from './fabric/NativePlaidLinkModuleiOS'; +// Choose the correct native module based on the platform. +// Use the Android or iOS implementation if available; otherwise, fallback to `undefined`. const RNLinksdk = (Platform.OS === 'android' ? RNLinksdkAndroid : RNLinksdkiOS) ?? undefined; /** * A hook that registers a listener on the Plaid emitter for the 'onEvent' type. - * The listener is cleaned up when this view is unmounted + * The listener is cleaned up when this component is unmounted. * - * @param LinkEventListener the listener to call + * @param {LinkEventListener} linkEventListener - The event listener to be called on 'onEvent'. */ export const usePlaidEmitter = (linkEventListener: LinkEventListener) => { useEffect(() => { + // Create a new event emitter for the Plaid native module const emitter = new NativeEventEmitter(RNLinksdk); const listener = emitter.addListener('onEvent', linkEventListener); - // Clean up after this effect: + + // Clean up the event listener when the component unmounts return function cleanup() { listener.remove(); }; }, []); }; +/** + * Initializes the Plaid Link SDK with the provided token configuration. + * + * @param {LinkTokenConfiguration} props - Configuration object containing the token and options. + */ export const create = (props: LinkTokenConfiguration) => { - let token = props.token; - let noLoadingState = props.noLoadingState ?? false; + let token = props.token; // The Link token to be used for initialization. + let noLoadingState = props.noLoadingState ?? false; // Default to `false` if `noLoadingState` is undefined. if (Platform.OS === 'android') { + // Android-specific initialization with log level RNLinksdkAndroid?.create( token, noLoadingState, - props.logLevel ?? LinkLogLevel.ERROR, + props.logLevel ?? LinkLogLevel.ERROR // Default log level is ERROR. ); } else { + // iOS-specific initialization RNLinksdkiOS?.create(token, noLoadingState); } }; +/** + * Opens the Plaid Link interface. + * + * @param {LinkOpenProps} props - Properties required to open the Plaid Link. + */ export const open = async (props: LinkOpenProps) => { if (Platform.OS === 'android') { + // Android-specific open implementation RNLinksdkAndroid?.open( (result: LinkSuccess) => { + // Handle successful session if (props.onSuccess != null) { props.onSuccess(result); } }, (result: LinkExit) => { + // Handle session exit with possible error if (props.onExit != null) { if (result.error != null && result.error.displayMessage != null) { - //TODO(RNSDK-118): Remove errorDisplayMessage field in next major update. + // Legacy field for error display message (to be removed in the next major update) result.error.errorDisplayMessage = result.error.displayMessage; } props.onExit(result); } - }, + } ); } else { + // Determine iOS presentation style let presentFullScreen = props.iOSPresentationStyle == LinkIOSPresentationStyle.FULL_SCREEN; RNLinksdkiOS?.open( presentFullScreen, (result: LinkSuccess) => { + // Handle successful session if (props.onSuccess != null) { props.onSuccess(result); } }, (error: LinkError, result: LinkExit) => { + // Handle session exit with possible error if (props.onExit != null) { if (error) { var data = result || {}; - data.error = error; + data.error = error; // Attach error details to the result. props.onExit(data); } else { props.onExit(result); } } - }, + } ); } }; +/** + * Dismisses the Plaid Link interface on iOS. + */ export const dismissLink = () => { if (Platform.OS === 'ios') { RNLinksdkiOS?.dismiss(); } }; +/** + * Submits additional data, such as a phone number, to the Plaid Link interface. + * + * @param {SubmissionData} data - Data to be submitted. + */ export const submit = (data: SubmissionData): void => { if (Platform.OS === 'android') { RNLinksdkAndroid?.submit(data.phoneNumber); @@ -110,42 +140,51 @@ export const submit = (data: SubmissionData): void => { }; /** - * Function to sync the user's transactions from their Apple card. + * Syncs the user's transactions from their Apple Card. * - * @param {string} token - The `LinkToken` your server retrieved from the /link/token/create endpoint from the Plaid API. - * This token must be associated with an accessToken. - * @param {boolean} requestAuthorizationIfNeeded - Indicates if the user should be prompted to authorize the sync if - * they have not already done so. - * @param {function} completion - A callback function that is called when the sync has completed. + * @param {string} token - The LinkToken retrieved from Plaid's /link/token/create endpoint. + * Must be associated with an accessToken. + * @param {boolean} requestAuthorizationIfNeeded - Whether to prompt the user for authorization if required. + * @param {function} completion - A callback function invoked upon completion with an error (if any). * * @warning This method only works on iOS >= 17.4. - * @warning This method is not supported on Android or MacCatalyst. - * @warning This method can only be used once the user has granted access to their Apple card via a standard Link Session. - * @warning This method requires that your app has been granted FinanceKit access from Apple. + * @warning Not supported on Android or MacCatalyst. + * @warning Requires prior authorization via a standard Link session and FinanceKit access. */ export const syncFinanceKit = ( token: string, requestAuthorizationIfNeeded: boolean, completion: (error?: FinanceKitError) => void ): void => { - if (Platform.OS === 'android') { - completion({ - type: FinanceKitErrorType.Unknown, - message: "FinanceKit is unavailable on Android!", - }) - } else { - RNLinksdkiOS?.syncFinanceKit( - token, - requestAuthorizationIfNeeded, - () => { - completion() - }, - (error: FinanceKitError) => { - completion({ - type: error.type, - message: error.message, - }) - } - ) + // Check platform compatibility + if (Platform.OS === 'android') { + completion({ + type: FinanceKitErrorType.Unknown, + message: 'FinanceKit is unavailable on Android!', + }); + return; + } + + // Ensure the iOS native module is available + if (!RNLinksdkiOS) { + completion({ + type: FinanceKitErrorType.Unknown, + message: 'FinanceKit module is not available on this platform!', + }); + return; + } + + // Call the iOS native method + RNLinksdkiOS.syncFinanceKit( + token, + requestAuthorizationIfNeeded, + () => { + // Success callback + completion(); + }, + (error: FinanceKitError) => { + // Error callback + completion(error); } + ); }; diff --git a/src/Types.ts b/src/Types.ts index fccb5a61..2318a587 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -579,37 +579,10 @@ export enum FinanceKitErrorType { Unknown = 4 } -interface InvalidTokenError { - type: FinanceKitErrorType.InvalidToken; - message: string; +export interface FinanceKitError { + type: FinanceKitErrorType; // Matches the error codes defined in the FinanceKitErrorType enum + message: string; // The error message provided by native } - -interface PermissionError { - type: FinanceKitErrorType.PermissionError; - message: string; -} - -interface LinkApiError { - type: FinanceKitErrorType.LinkApiError; - message: string; -} - -interface PermissionAccessError { - type: FinanceKitErrorType.PermissionAccessError; - message: string; -} - -interface UnknownError { - type: FinanceKitErrorType.Unknown; - message: string; -} - -export type FinanceKitError = - | InvalidTokenError - | PermissionError - | LinkApiError - | PermissionAccessError - | UnknownError; export interface SubmissionData { phoneNumber?: string; diff --git a/src/fabric/EmbeddedLinkViewNativeComponent.ts b/src/fabric/EmbeddedLinkViewNativeComponent.ts index 7167025f..c0e65037 100644 --- a/src/fabric/EmbeddedLinkViewNativeComponent.ts +++ b/src/fabric/EmbeddedLinkViewNativeComponent.ts @@ -1,24 +1,24 @@ -import type { ViewProps } from 'react-native'; -import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; -// @ts-ignore getting the types from the codegen -import { DirectEventHandler, UnsafeMixed } from 'react-native/Libraries/Types/CodegenTypes'; +// import type { ViewProps } from 'react-native'; +// import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +// // @ts-ignore getting the types from the codegen +// import { DirectEventHandler, UnsafeMixed } from 'react-native/Libraries/Types/CodegenTypes'; -export interface NativeProps extends ViewProps { - token?: string; - iOSPresentationStyle?: string; - onEmbeddedEvent: DirectEventHandler<{ - embeddedEventName: string; - // for EmbeddedEvent - eventName?: string; - // for EmbeddedExit - error?: UnsafeMixed; - // for EmbeddedSuccess - publicToken?: string; - // for all of them - metadata?: UnsafeMixed; - }>; -} +// export interface NativeProps extends ViewProps { +// token?: string; +// iOSPresentationStyle?: string; +// onEmbeddedEvent: DirectEventHandler<{ +// embeddedEventName: string; +// // for EmbeddedEvent +// eventName?: string; +// // for EmbeddedExit +// error?: UnsafeMixed; +// // for EmbeddedSuccess +// publicToken?: string; +// // for all of them +// metadata?: UnsafeMixed; +// }>; +// } -export default codegenNativeComponent( - 'PLKEmbeddedView', -); +// export default codegenNativeComponent( +// 'PLKEmbeddedView', +// ); diff --git a/src/fabric/NativePlaidLinkModuleAndroid.ts b/src/fabric/NativePlaidLinkModuleAndroid.ts index 1f912b94..ac9b0f40 100644 --- a/src/fabric/NativePlaidLinkModuleAndroid.ts +++ b/src/fabric/NativePlaidLinkModuleAndroid.ts @@ -1,16 +1,43 @@ -// we use Object type because methods on the native side use NSDictionary and ReadableMap -// and we want to stay compatible with those +// Import necessary modules and types from React Native and other utilities +// TurboModuleRegistry and TurboModule are used to define and register TurboModules, which are native modules +// UnsafeObject is a utility type used to maintain compatibility with native objects like NSDictionary and ReadableMap import {TurboModuleRegistry, TurboModule} from 'react-native'; import {Double} from 'react-native/Libraries/Types/CodegenTypes'; import {UnsafeObject} from './fabricUtils'; import {LinkSuccess, LinkExit} from '../Types'; +// Define the Spec interface, which extends TurboModule +// This interface represents the shape of the native module that we are exposing to JavaScript export interface Spec extends TurboModule { + /** + * Initializes the Plaid module with the provided Link token and configuration. + * + * @param token - The Link token used to authenticate the session. + * @param noLoadingState - Whether to disable the loading state in the UI. + * @param logLevel - The logging level for debugging or error reporting. + */ create(token: string, noLoadingState: boolean, logLevel: string): void; + + /** + * Opens the Plaid Link session and handles the success and exit callbacks. + * + * @param onSuccess - Callback function executed when the session completes successfully. + * @param onExit - Callback function executed when the session exits, either by user action or an error. + */ open( onSuccess: (result: UnsafeObject) => void, onExit: (result: UnsafeObject) => void, ): void; + + /** + * Starts the Link activity for result on Android, allowing users to link accounts. + * + * @param token - The Link token used to authenticate the session. + * @param noLoadingState - Whether to disable the loading state in the UI. + * @param logLevel - The logging level for debugging or error reporting. + * @param onSuccessCallback - Callback function executed when the session completes successfully. + * @param onExitCallback - Callback function executed when the session exits, either by user action or an error. + */ startLinkActivityForResult( token: string, noLoadingState: boolean, @@ -18,10 +45,29 @@ export interface Spec extends TurboModule { onSuccessCallback: (result: UnsafeObject) => void, onExitCallback: (result: UnsafeObject) => void ): void; + + /** + * Submits a phone number to the Plaid module, typically used for verification or similar purposes. + * + * @param phoneNumber - The phone number to submit, or undefined if not available. + */ submit(phoneNumber: string | undefined): void; - // those two are here for event emitter methods + + /** + * Adds an event listener for the specified event name. + * + * @param eventName - The name of the event to listen for. + */ addListener(eventName: string): void; + + /** + * Removes the specified number of event listeners. + * + * @param count - The number of listeners to remove. + */ removeListeners(count: Double): void; } +// Export the default instance of the PlaidAndroid TurboModule +// This retrieves the native module implementation, if available, and provides it to the JavaScript layer. export default TurboModuleRegistry.get('PlaidAndroid'); diff --git a/src/fabric/NativePlaidLinkModuleiOS.ts b/src/fabric/NativePlaidLinkModuleiOS.ts index a3e7de4a..76cb2e3f 100644 --- a/src/fabric/NativePlaidLinkModuleiOS.ts +++ b/src/fabric/NativePlaidLinkModuleiOS.ts @@ -1,28 +1,81 @@ -// we use Object type because methods on the native side use NSDictionary and ReadableMap -// and we want to stay compatible with those -import {TurboModuleRegistry, TurboModule} from 'react-native'; -import {Int32} from 'react-native/Libraries/Types/CodegenTypes'; -import {UnsafeObject} from './fabricUtils'; -import {LinkSuccess, LinkExit, LinkError, FinanceKitError} from '../Types'; +// Import necessary modules for defining the TurboModule interface +import {TurboModuleRegistry, TurboModule} from 'react-native'; // TurboModule system for native modules +import {Int32} from 'react-native/Libraries/Types/CodegenTypes'; // Codegen-friendly integer type +import {UnsafeObject} from './fabricUtils'; // Utility for handling dynamic or loosely-typed objects +import {LinkSuccess, LinkExit, LinkError, FinanceKitError} from '../Types'; // Type definitions for Link SDK operations +// Define the Spec interface, which represents the methods exposed by the native RNLinksdk module export interface Spec extends TurboModule { + + /** + * Initializes the Link handler with the provided token. + * @param token - The authentication token used to configure Link. + * @param noLoadingState - If true, skips showing the loading state in the UI. + */ create(token: string, noLoadingState: boolean): void; + + /** + * Opens the Link experience. + * Must be called after the `create` method has been successfully invoked. + * @param fullScreen - If true, opens the Link UI in full-screen mode. + * @param onSuccess - Callback invoked when Link completes successfully. + * @param onExit - Callback invoked when the user exits the Link UI. + */ open( fullScreen: boolean, onSuccess: (result: UnsafeObject) => void, onExit: (error: UnsafeObject, result: UnsafeObject) => void, - ): void; + ): void; + + /** + * Dismisses the Link UI. + * Can be used to programmatically close the Link experience. + */ dismiss(): void; + + /** + * Submits the provided phone number for eligibility checks. + * @param phoneNumber - The user's phone number, or undefined if not provided. + */ submit(phoneNumber: string | undefined): void; - // those two are here for event emitter methods + + /** + * Registers a listener for a specific event emitted by the module. + * @param eventName - The name of the event to listen for. + */ addListener(eventName: string): void; + + /** + * Removes a specified number of event listeners. + * @param count - The number of listeners to remove. + */ removeListeners(count: Int32): void; + + /** + * Synchronizes the user's transactions and data using FinanceKit. + * + * @param token - The authentication token required to access FinanceKit services. + * This token must be linked to an access token from the Plaid API. + * @param requestAuthorizationIfNeeded - If true, prompts the user to authorize the sync + * if permissions have not been previously granted. + * @param onSuccess - Callback function invoked when the synchronization completes successfully. + * This function is called with no arguments. + * @param onError - Callback function invoked when an error occurs during synchronization. + * The error object includes the following properties: + * - `type`: A string representing the specific FinanceKit error type. + * - `message`: A human-readable description of the error. + * + * @warning This method is supported only on iOS devices running version 17.4 or higher. + * @warning Ensure that your app has been granted FinanceKit access from Apple. + * @warning This method cannot be used on Android or Mac Catalyst platforms. + */ syncFinanceKit( - token: string, - requestAuthorizationIfNeeded: boolean, - onSuccess: (success: void) => void, - onError: (error: FinanceKitError) => void - ): void + token: string, + requestAuthorizationIfNeeded: boolean, + onSuccess: () => void, + onError: (error: UnsafeObject) => void + ): void; } +// Export the TurboModule registry entry for this module, enabling JavaScript access. export default TurboModuleRegistry.get('RNLinksdk'); diff --git a/src/index.ts b/src/index.ts index c5edfa46..79893529 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,4 +20,4 @@ export { // Components -export { EmbeddedLinkView } from './EmbeddedLink/EmbeddedLinkView'; +// export { EmbeddedLinkView } from './EmbeddedLink/EmbeddedLinkView';