diff --git a/demo/Collect.tsx b/demo/Collect.tsx index c6e65ec..0d8b2b8 100644 --- a/demo/Collect.tsx +++ b/demo/Collect.tsx @@ -24,6 +24,7 @@ import type { import { styles } from './styles'; import type { ElementEvents } from '../App'; import { EncryptedToken, EncryptToken } from '../src/model/EncryptTokenData'; +import { BasisTheoryProvider } from '../src/BasisTheoryProvider'; const Divider = () => ; @@ -184,126 +185,128 @@ export const Collect = () => { - - - - - - - - - {'Create token'} - - - - {'Update Token'} - - - - {'Delete Token'} - - - - - - {'Tokenize Data'} - - - - {'Encrypt Token'} - - - - - - {'Clear'} - - - {token && ( - <> - - TOKEN: - - - {JSON.stringify(token, undefined, 2)} - - - )} - - {tokenizedData && ( - <> - - TOKENIZED DATA: - - {JSON.stringify(tokenizedData, undefined, 2)} - - - )} - - {encryptedToken && ( - <> - - ENCRYPTED TOKEN: - - {JSON.stringify(encryptedToken, undefined, 2)} - - - )} - + + + + + + + + + + {'Create token'} + + + + {'Update Token'} + + + + {'Delete Token'} + + + + + + {'Tokenize Data'} + + + + {'Encrypt Token'} + + + + + + {'Clear'} + + + {token && ( + <> + + TOKEN: + + + {JSON.stringify(token, undefined, 2)} + + + )} + + {tokenizedData && ( + <> + + TOKENIZED DATA: + + {JSON.stringify(tokenizedData, undefined, 2)} + + + )} + + {encryptedToken && ( + <> + + ENCRYPTED TOKEN: + + {JSON.stringify(encryptedToken, undefined, 2)} + + + )} + + ); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0993b31..fb827f1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -8,7 +8,6 @@ PODS: - hermes-engine (0.79.5): - hermes-engine/Pre-built (= 0.79.5) - hermes-engine/Pre-built (0.79.5) - - OpenSSL-Universal (3.3.3001) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -1335,32 +1334,6 @@ PODS: - ReactCommon/turbomodule/core - react-native-get-random-values (1.11.0): - React-Core - - react-native-quick-crypto (0.7.14): - - DoubleConversion - - glog - - hermes-engine - - OpenSSL-Universal - - RCT-Folly (= 2024.11.18.00) - - RCTRequired - - RCTTypeSafety - - React - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-hermes - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - react-native-safe-area-context (5.5.1): - DoubleConversion - glog @@ -1854,7 +1827,6 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -1892,7 +1864,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - OpenSSL-Universal - SocketRocket EXTERNAL SOURCES: @@ -1975,8 +1946,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-get-random-values: :path: "../node_modules/react-native-get-random-values" - react-native-quick-crypto: - :path: "../node_modules/react-native-quick-crypto" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" React-NativeModulesApple: @@ -2054,7 +2023,6 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: f03b0e06d3882d71e67e45b073bb827da1a21aae - OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5 RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8 @@ -2087,7 +2055,6 @@ SPEC CHECKSUMS: React-Mapbuffer: 96a2f2a176268581733be182fa6eebab1c0193be React-microtasksnativemodule: bda561d2648e1e52bd9e5a87f8889836bdbde2e2 react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba - react-native-quick-crypto: dcf29d2a08af7b16fb0ee9ef2327585743300ed5 react-native-safe-area-context: 091add53da15b67c76b176724725581b29a1cde3 React-NativeModulesApple: 1ecb83880dd11baf2228f8dd89d8419c387e03ad React-oscompat: 0592889a9fcf0eacb205532028e4a364e22907dd diff --git a/src/BasisTheoryProvider.tsx b/src/BasisTheoryProvider.tsx index ecbb27a..3fe331c 100644 --- a/src/BasisTheoryProvider.tsx +++ b/src/BasisTheoryProvider.tsx @@ -2,12 +2,49 @@ import type { PropsWithChildren } from 'react'; import React, { createContext, useContext, useMemo } from 'react'; import type { BasisTheoryElements } from './useBasisTheory'; +type BasisTheoryConfig = { + apiKey?: string; + baseUrl?: string; +}; + type BasisTheoryProviderType = { bt?: BasisTheoryElements; + config?: BasisTheoryConfig; }; const BasisTheoryContext = createContext({}); +const ConfigManager = (() => { + let internalConfig: BasisTheoryConfig = {}; + + const updateConfig = (updates: Partial): void => { + internalConfig = { ...internalConfig, ...updates }; + }; + + const setConfig = (config: BasisTheoryConfig): void => { + console.log(config); + internalConfig = config; + }; + + const getConfig = (): BasisTheoryConfig => internalConfig; + + const updateApiKey = (apiKey: string): void => { + internalConfig = { ...internalConfig, apiKey }; + }; + + const updateBaseUrl = (baseUrl: string): void => { + internalConfig = { ...internalConfig, baseUrl }; + }; + + return { + updateConfig, + setConfig, + getConfig, + updateApiKey, + updateBaseUrl, + }; +})(); + const BasisTheoryProvider = ({ bt, children, @@ -15,6 +52,7 @@ const BasisTheoryProvider = ({ const value = useMemo( () => ({ bt, + config: ConfigManager.getConfig(), }), [bt] ); @@ -29,4 +67,12 @@ const BasisTheoryProvider = ({ const useBasisTheoryFromContext = (): BasisTheoryProviderType => useContext(BasisTheoryContext); -export { BasisTheoryProvider, useBasisTheoryFromContext }; +const useBasisTheoryConfig = () => useContext(BasisTheoryContext).config; + +// Internal hook for surgical config updates (not exported - private) +const useConfigManager = () => ConfigManager; + +export { BasisTheoryProvider, useBasisTheoryFromContext, useBasisTheoryConfig }; + +// Export for internal use only (not part of public API) +export { useConfigManager as _useConfigManager }; diff --git a/src/CardElementTypes.ts b/src/CardElementTypes.ts new file mode 100644 index 0000000..96a5c12 --- /dev/null +++ b/src/CardElementTypes.ts @@ -0,0 +1,55 @@ +export const CARD_BRANDS = [ + 'accel', + 'bancontact', + 'cartes-bancaires', + 'culiance', + 'dankort', + 'ebt', + 'eftpos-australia', + 'nyce', + 'private-label', + 'prop', + 'pulse', + 'rupay', + 'star', + 'uatp', + 'korean-local', + 'visa', + 'mastercard', + 'american-express', + 'discover', + 'diners-club', + 'jcb', + 'unionpay', + 'maestro', + 'elo', + 'hiper', + 'hipercard', + 'mir', + 'unknown', +] as const; + +export type CardBrand = (typeof CARD_BRANDS)[number]; + +export enum CoBadgedSupport { + CartesBancaires = 'cartes-bancaires', +} + +interface CardIssuerDetails { + country: string; + name: string; + } + interface CardInfo { + brand: string; + funding: string; + issuer: CardIssuerDetails; + } + + export interface BinInfo { + brand: string; + funding: string; + issuer: CardIssuerDetails; + segment: string; + additional?: CardInfo[]; + } + \ No newline at end of file diff --git a/src/ElementValues.ts b/src/ElementValues.ts index 5170306..404915f 100644 --- a/src/ElementValues.ts +++ b/src/ElementValues.ts @@ -1,14 +1,29 @@ import { type PrimitiveType } from './BaseElementTypes'; +import type { BinInfo } from './CardElementTypes'; +import type { CardBrand } from './CardElementTypes'; /** * If `_elementValues` requires any modification, we should start looking for a better state management solution. */ const _elementValues: Record = {}; +/** + * If `_elementRawValues` are used to store the raw value of the element. + */ +const _elementRawValues: Record = {}; + +/** + * If `_elementMetadata` are used to store the metadata of the element. + */ +const _elementMetadata: Record = {}; + /** * `_elementErrors` are used to validate the payload before it's sent to the API. If not empty the request won't be made. * If these require any modification we should start looking for a better state management solution. */ const _elementErrors: Record = {}; -export { _elementErrors, _elementValues }; +export { _elementErrors, _elementValues, _elementMetadata, _elementRawValues }; diff --git a/src/components/BrandPicker.tsx b/src/components/BrandPicker.tsx new file mode 100644 index 0000000..ed7927d --- /dev/null +++ b/src/components/BrandPicker.tsx @@ -0,0 +1,160 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Text, View, TouchableOpacity, Modal, ScrollView, ViewStyle, TextStyle } from 'react-native'; +import { CardBrand } from '../CardElementTypes'; +import { labelizeCardBrand } from '../utils/shared'; + +interface BrandPickerProps { + brands: CardBrand[]; + selectedBrand: CardBrand | undefined; + onBrandSelect: (brand: CardBrand | undefined) => void; + style?: ViewStyle; +} + +const defaultStyles = { + container: { + marginBottom: 10, + }, + buttonText: { + color: '#374151', + fontSize: 16, + }, + placeholderText: { + color: '#9ca3af', + fontSize: 16, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + modalContainer: { + backgroundColor: '#ffffff', + borderRadius: 12, + minWidth: 250, + maxWidth: 300, + borderWidth: 1, + borderColor: '#d1d5db', + }, + modalTitle: { + color: '#374151', + fontSize: 18, + fontWeight: '600' as const, + textAlign: 'center' as const, + paddingVertical: 15, + borderBottomWidth: 1, + borderBottomColor: '#d1d5db', + }, + scrollView: { + maxHeight: 200, + }, + optionButton: { + paddingVertical: 15, + paddingHorizontal: 20, + borderBottomColor: '#d1d5db', + }, + selectedOption: { + backgroundColor: '#f3f4f6', + }, + optionText: { + color: '#374151', + fontSize: 16, + textAlign: 'center' as const, + }, +}; + +const isTextStyle = (style: ViewStyle | undefined): style is TextStyle & ViewStyle => { + return style !== undefined && + typeof style === 'object' && + 'color' in style; +}; + +export const BrandPicker: React.FC = ({ + brands, + selectedBrand, + onBrandSelect, + style, +}) => { + const [pickerVisible, setPickerVisible] = useState(false); + + const buttonTextStyle = useMemo(() => { + if (isTextStyle(style) && style.color) { + return { color: style.color }; + } + return defaultStyles.buttonText; + }, [style]); + + const displayText = useMemo(() => { + return selectedBrand ? labelizeCardBrand(selectedBrand as CardBrand) : 'Select card brand'; + }, [selectedBrand]); + + const shouldRender = useMemo(() => brands.length > 0, [brands]); + + const handleShowPicker = useCallback(() => { + setPickerVisible(true); + }, []); + + const handleHidePicker = useCallback(() => { + setPickerVisible(false); + }, []); + + const handleBrandSelect = useCallback((brand: CardBrand) => { + onBrandSelect(brand); + setPickerVisible(false); + }, [onBrandSelect]); + + + if (!shouldRender) { + return null; + } + + return ( + + + + {displayText} + + + + + + + + Select Card Brand + + + {brands.map((brand, index) => ( + handleBrandSelect(brand)} + style={[ + defaultStyles.optionButton, + { + borderBottomWidth: index === brands.length - 1 ? 0 : 1, + }, + selectedBrand === brand && defaultStyles.selectedOption, + ]} + > + + {labelizeCardBrand(brand)} + + + ))} + + + + + + ); +}; diff --git a/src/components/CardNumberElement.hook.ts b/src/components/CardNumberElement.hook.ts index 4d9d78d..9fd6fed 100644 --- a/src/components/CardNumberElement.hook.ts +++ b/src/components/CardNumberElement.hook.ts @@ -12,12 +12,16 @@ import { useBtRefUnmount } from './shared/useBtRefUnmount'; import { useMask } from './shared/useMask'; import { useUserEventHandlers } from './shared/useUserEventHandlers'; import { useCustomBin } from './useCustomBin.hook'; +import { useBinLookup } from './useBinLookup'; import { useCleanupStateBeforeUnmount } from './shared/useCleanStateOnUnmount'; +import { CardBrand, CoBadgedSupport } from '../CardElementTypes'; type UseCardNumberElementProps = { btRef?: ForwardedRef; cardTypes?: CreditCardType[]; skipLuhnValidation?: boolean; + binLookup?: boolean; + coBadgedSupport?: CoBadgedSupport[]; } & EventConsumers; export const useCardNumberElement = ({ @@ -27,12 +31,33 @@ export const useCardNumberElement = ({ onFocus, cardTypes, skipLuhnValidation, + binLookup, + coBadgedSupport, }: UseCardNumberElementProps) => { + + const hasCoBadgedSupport = (coBadgedSupport?.length ?? 0) > 0; + + if (hasCoBadgedSupport && coBadgedSupport) { + const validValues = Object.values(CoBadgedSupport); + const invalidValues = coBadgedSupport.filter(value => !validValues.includes(value)); + + if (invalidValues.length > 0) { + throw new Error( + `Invalid coBadgedSupport values: ${invalidValues.join(', ')}. ` + + `Valid values are: ${validValues.join(', ')}` + ); + } + } + const id = useId(); const type = ElementType.CARD_NUMBER; const elementRef = useRef(null); const [elementValue, setElementValue] = useState(''); + const [selectedNetwork, setSelectedNetwork] = useState(undefined); + + const binEnabled = binLookup || hasCoBadgedSupport; + const { binInfo } = useBinLookup(binEnabled, elementValue.replaceAll(' ', '').slice(0, 6)); useCleanupStateBeforeUnmount(id); @@ -57,8 +82,12 @@ export const useCardNumberElement = ({ transform: [' ', ''], element: { id, - validatorOptions: { mask, skipLuhnValidation }, + validatorOptions: { mask, skipLuhnValidation, coBadgedSupport }, type, + binLookup, + coBadgedSupport, + binInfo, + selectedNetwork }, onChange, onBlur, @@ -68,6 +97,9 @@ export const useCardNumberElement = ({ return { elementRef, elementValue, + selectedNetwork, + setSelectedNetwork, + binInfo, _onChange, _onBlur, _onFocus, diff --git a/src/components/CardNumberElement.tsx b/src/components/CardNumberElement.tsx index 226dcb6..ef68dfb 100644 --- a/src/components/CardNumberElement.tsx +++ b/src/components/CardNumberElement.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import type { TextInputProps } from 'react-native'; +import { View, type TextInputProps, type ViewStyle } from 'react-native'; import MaskInput from 'react-native-mask-input'; import type { UseCardNumberElementProps } from './CardNumberElement.hook'; import { useCardNumberElement } from './CardNumberElement.hook'; +import { useBrandSelector } from './shared/useBrandSelector'; +import { BrandPicker } from './BrandPicker'; type TextInputSupportedProps = | 'editable' @@ -17,40 +19,70 @@ type CardNumberProps = UseCardNumberElementProps & export const CardNumberElement = ({ btRef, cardTypes, - editable, - keyboardType, onBlur, onChange, onFocus, + keyboardType = 'numeric', placeholder, placeholderTextColor, + editable = true, skipLuhnValidation, + binLookup, + coBadgedSupport, style, }: CardNumberProps) => { - const { elementRef, _onChange, _onBlur, _onFocus, elementValue, mask } = - useCardNumberElement({ - btRef, - onBlur, - onChange, - onFocus, - cardTypes, - skipLuhnValidation, - }); + const { + elementRef, + _onChange, + _onBlur, + _onFocus, + elementValue, + mask, + binInfo, + selectedNetwork, + setSelectedNetwork + } = useCardNumberElement({ + btRef, + onBlur, + onChange, + onFocus, + cardTypes, + skipLuhnValidation, + binLookup, + coBadgedSupport, + }); + + const { brandSelectorOptions } = useBrandSelector({ + binInfo, + coBadgedSupport, + selectedNetwork, + setSelectedNetwork, + }); return ( - + + {brandSelectorOptions.length > 1 && ( + + )} + + ); }; diff --git a/src/components/shared/useBrandSelector.ts b/src/components/shared/useBrandSelector.ts new file mode 100644 index 0000000..27b12cd --- /dev/null +++ b/src/components/shared/useBrandSelector.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo } from 'react'; +import { CardBrand, CoBadgedSupport } from '../../CardElementTypes'; +import { convertApiBrandToBrand } from '../../utils/shared'; +import { isNilOrEmpty } from '../../utils/shared'; +import type { BinInfo } from '../../CardElementTypes'; + +interface UseBrandSelectorProps { + binInfo?: BinInfo; + coBadgedSupport?: CoBadgedSupport[]; + selectedNetwork?: CardBrand; + setSelectedNetwork: (brand: CardBrand | undefined) => void; +} + +interface UseBrandSelectorReturn { + brandSelectorOptions: CardBrand[]; +} + +export const useBrandSelector = ({ + binInfo, + coBadgedSupport, + selectedNetwork, + setSelectedNetwork, +}: UseBrandSelectorProps): UseBrandSelectorReturn => { + const hasCoBadgedSupport = useMemo(() => + !isNilOrEmpty(coBadgedSupport), + [coBadgedSupport] + ); + + const brandSelectorOptions = useMemo(() => { + if (!binInfo) return []; + + const { brand, additional } = binInfo; + const brandOptions = new Set(); + + brandOptions.add(convertApiBrandToBrand(brand)); + + additional?.forEach((a) => { + if (!a.brand) return; + const brand = convertApiBrandToBrand(a.brand); + if (hasCoBadgedSupport && coBadgedSupport?.includes(brand as CoBadgedSupport)) { + brandOptions.add(brand); + } + }); + + return Array.from(brandOptions); + }, [binInfo, coBadgedSupport]); + + // Reset selected network when binInfo is cleared + useEffect(() => { + if (!binInfo && selectedNetwork) { + setSelectedNetwork(undefined); + } + }, [binInfo, selectedNetwork, setSelectedNetwork]); + + return { + brandSelectorOptions, + }; +}; \ No newline at end of file diff --git a/src/components/shared/useElementEvent.ts b/src/components/shared/useElementEvent.ts index 299c68d..e78484f 100644 --- a/src/components/shared/useElementEvent.ts +++ b/src/components/shared/useElementEvent.ts @@ -7,17 +7,27 @@ import { useCardMetadata } from './useCardMetadata'; import { extractDigits, isNilOrEmpty } from '../../utils/shared'; import { _elementErrors } from '../../ElementValues'; import { ValidatorOptions } from '../../utils/validation'; +import { BinInfo } from '../../CardElementTypes'; +import { CardBrand } from '../../CardElementTypes'; type UseElementEventProps = { type: ElementType; id: string; validatorOptions?: ValidatorOptions; + binInfo?: BinInfo; + selectedNetwork?: CardBrand; + binLookup?: boolean; + coBadgedSupport?: CardBrand[]; }; export const useElementEvent = ({ type, id, validatorOptions, + binInfo, + selectedNetwork, + binLookup, + coBadgedSupport, }: UseElementEventProps): CreateEvent => { const { getValidationStrategy } = useElementValidation(); const { getMetadataFromCardNumber: _getMetadataFromCardNumber } = @@ -80,6 +90,8 @@ export const useElementEvent = ({ maskSatisfied, complete, ...metadata?.card, + ...(binLookup ? { binInfo } : {}), + ...(!isNilOrEmpty(coBadgedSupport) ? { selectedNetwork } : {}), }; }; }; diff --git a/src/components/shared/useUserEventHandlers.ts b/src/components/shared/useUserEventHandlers.ts index 7c66d54..66e550a 100644 --- a/src/components/shared/useUserEventHandlers.ts +++ b/src/components/shared/useUserEventHandlers.ts @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -import { _elementValues } from '../../ElementValues'; +import { useEffect } from 'react'; +import { _elementValues, _elementMetadata, _elementRawValues } from '../../ElementValues'; import { useElementEvent } from './useElementEvent'; import type { ElementType, EventConsumers } from '../../BaseElementTypes'; import type { TransformType } from './useTransform'; @@ -7,6 +8,7 @@ import { useTransform } from './useTransform'; import { ValidatorOptions } from '../../utils/validation'; import { NativeSyntheticEvent, TextInputFocusEventData } from 'react-native'; import { isString } from '../../utils/shared'; +import { BinInfo, CardBrand } from '../../CardElementTypes'; type UseUserEventHandlers = { setElementValue: Dispatch>; @@ -14,6 +16,10 @@ type UseUserEventHandlers = { id: string; type: ElementType; validatorOptions?: ValidatorOptions; + binLookup?: boolean; + coBadgedSupport?: CardBrand[]; + binInfo?: BinInfo; + selectedNetwork?: CardBrand }; transform?: TransformType; } & EventConsumers; @@ -30,8 +36,33 @@ export const useUserEventHandlers = ({ const transformation = useTransform(transform); + useEffect(() => { + const currentBinInfo = element.binInfo; + const prevBinInfo = _elementMetadata[element.id]?.binInfo; + + if (currentBinInfo !== prevBinInfo && onChange) { + const event = createEvent(_elementRawValues[element.id]?.toString() || ''); + onChange(event); + } + + _elementMetadata[element.id] = { ..._elementMetadata[element.id], binInfo: currentBinInfo }; + }, [element.binInfo, onChange, createEvent, element.id]); + + useEffect(() => { + const currentSelectedNetwork = element.selectedNetwork; + const prevSelectedNetwork = _elementMetadata[element.id]?.selectedNetwork; + + if (currentSelectedNetwork !== prevSelectedNetwork && onChange) { + const event = createEvent(_elementRawValues[element.id]?.toString() || ''); + onChange(event); + } + + _elementMetadata[element.id] = { ..._elementMetadata[element.id], selectedNetwork: currentSelectedNetwork }; + }, [element.selectedNetwork, onChange, createEvent, element.id]); + return { _onChange: (_elementValue: string) => { + _elementRawValues[element.id] = _elementValue; _elementValues[element.id] = transformation.apply(_elementValue); setElementValue(() => { diff --git a/src/components/useBinLookup.ts b/src/components/useBinLookup.ts new file mode 100644 index 0000000..1446c08 --- /dev/null +++ b/src/components/useBinLookup.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react'; +import { + _useConfigManager, + useBasisTheoryFromContext, +} from '../BasisTheoryProvider'; +import type { BasisTheoryElements } from '../useBasisTheory'; +import type { BinInfo } from '../CardElementTypes'; + +export const getBinInfo = async ( + bt: BasisTheoryElements, + bin: string +): Promise => { + const { getConfig } = _useConfigManager(); + const { apiKey, baseUrl } = getConfig(); + const url = `${baseUrl}/enrichments/card-details?bin=${bin}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'BT-API-KEY': apiKey, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return (data as BinInfo) || undefined; +}; + +export const useBinLookup = (enabled: boolean, bin: string) => { + const { bt } = useBasisTheoryFromContext(); + + const [binInfo, setBinInfo] = useState(undefined); + const lastBinRef = useRef(undefined); + const cache = useRef>(new Map()); + + useEffect(() => { + if (!enabled || !bin || bin.length !== 6) { + setBinInfo(undefined); + lastBinRef.current = undefined; + return; + } + + if (bin === lastBinRef.current) { + return; + } + + const fetchBinInfo = async () => { + if (cache.current.has(bin)) { + setBinInfo(cache.current.get(bin)); + lastBinRef.current = bin; + return; + } + + try { + const result = await getBinInfo(bt!, bin); + setBinInfo(result); + cache.current.set(bin, result); + lastBinRef.current = bin; + } catch (err: unknown) { + if (err instanceof Error) { + console.error('BIN lookup failed:', err); + } + } + }; + + fetchBinInfo(); + }, [bin, enabled]); + + return { binInfo }; +}; diff --git a/src/useBasisTheory.ts b/src/useBasisTheory.ts index 9f669b4..69bca39 100644 --- a/src/useBasisTheory.ts +++ b/src/useBasisTheory.ts @@ -5,7 +5,10 @@ import type { } from '@basis-theory/basis-theory-js/types/sdk'; import { useEffect, useState } from 'react'; -import { useBasisTheoryFromContext } from './BasisTheoryProvider'; +import { + _useConfigManager, + useBasisTheoryFromContext, +} from './BasisTheoryProvider'; import { Proxy } from './modules/proxy'; import { Sessions } from './modules/sessions'; import { TokenIntents } from './modules/tokenIntents'; @@ -19,6 +22,9 @@ const _BasisTheoryElements = async ({ apiKey, apiBaseUrl ? { apiBaseUrl } : undefined ); + const { setConfig } = _useConfigManager(); + + setConfig({ apiKey, baseUrl: apiBaseUrl ?? 'https://api.basistheory.com' }); const proxy = Proxy(bt); diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 97eacc8..f2dae5e 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -1,6 +1,7 @@ import type { Token } from '@basis-theory/basis-theory-js/types/models'; import { anyPass, equals, is, isEmpty, isNil, replace, type } from 'ramda'; import type { BTRef, InputBTRefWithDatepart } from '../BaseElementTypes'; +import type { CardBrand } from '../CardElementTypes'; const isString = is(String); const isBoolean = is(Boolean); @@ -31,8 +32,56 @@ const isPrimitive = anyPass([isNil, isString, isBoolean, isNumber]); const filterOutMaxOccurrences = (numbers: number[]) => numbers.filter((num) => num !== Math.max(...numbers)); + +const convertApiBrandToBrand = (apiBrandName: string): CardBrand => { + const exceptions: Record = { + AMEX: 'american-express', + 'DINERS CLUB': 'diners-club', + 'CARTES BANCAIRES': 'cartes-bancaires', + EFTPOS_AUSTRALIA: 'eftpos-australia', + 'KOREAN LOCAL': 'korean-local', + 'PRIVATE LABEL': 'private-label', + }; + + const upperCaseName = apiBrandName.toUpperCase(); + + if (exceptions[upperCaseName]) { + return exceptions[upperCaseName]; + } + + const converted = apiBrandName.toLowerCase().replace(/[\s_]+/g, '-') as CardBrand; + return converted || 'unknown'; +}; + +const labelizeCardBrand = (value: CardBrand): string => { + const exceptions: Record = { + 'american-express': 'American Express', + 'diners-club': 'Diners Club', + 'cartes-bancaires': 'Cartes Bancaires', + 'eftpos-australia': 'EFTPOS Australia', + 'private-label': 'Private Label', + 'korean-local': 'Korean Local', + jcb: 'JCB', + unionpay: 'UnionPay', + hipercard: 'Hipercard', + uapt: 'UATP', + }; + + if (exceptions[value]) { + return exceptions[value]; + } + + return value + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + + export { + convertApiBrandToBrand, extractDigits, + labelizeCardBrand, filterOutMaxOccurrences, isBoolean, isBtDateRef, diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 1510a27..6c7f5ad 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -2,6 +2,7 @@ import { cvv, expirationDate, number } from 'card-validator'; import { isEmpty, partial, split } from 'ramda'; import type { Mask, ValidationResult } from '../BaseElementTypes'; import { ElementType } from '../BaseElementTypes'; +import { CoBadgedSupport } from '../CardElementTypes'; import { extractDigits, filterOutMaxOccurrences, @@ -21,6 +22,7 @@ const _cardCvvValidator = (length = [3, 4], value: string) => interface CardNumberValidatorOptions { skipLuhnValidation?: boolean; + coBadgedSupport?: CoBadgedSupport[]; } interface TextValidatorOptions { diff --git a/tests/components/CardNumberElement.test.tsx b/tests/components/CardNumberElement.test.tsx index 2508d33..a79f270 100644 --- a/tests/components/CardNumberElement.test.tsx +++ b/tests/components/CardNumberElement.test.tsx @@ -11,9 +11,16 @@ import { userEvent, fireEvent, screen, + waitFor, } from '@testing-library/react-native'; import { CardNumberElement } from '../../src'; +import { BasisTheoryProvider } from '../../src/BasisTheoryProvider'; +import { CoBadgedSupport } from '../../src/CardElementTypes'; import cardValidator from 'card-validator'; +import * as useBinLookupModule from '../../src/components/useBinLookup'; + +// Mock fetch for bin lookup tests +global.fetch = jest.fn(); describe('CardNumberElement', () => { beforeEach(() => { @@ -372,4 +379,518 @@ describe('CardNumberElement', () => { }); }); }); + + describe('Co-badge Support', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfo = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [ + { + brand: 'cartes-bancaires', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock the useBinLookup hook + jest.spyOn(useBinLookupModule, 'useBinLookup').mockReturnValue({ + binInfo: mockBinInfo, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('renders BrandPicker when coBadgedSupport is provided', () => { + const onChange = jest.fn(); + + render( + + + + ); + + // BrandPicker should be rendered (it shows "Select card brand" when no brand is selected) + expect(screen.getByText('Select card brand')).toBeTruthy(); + }); + + test('does not render BrandPicker when coBadgedSupport is not provided', () => { + const onChange = jest.fn(); + + render( + + + + ); + + // BrandPicker should not be rendered + expect(screen.queryByText('Select card brand')).toBeNull(); + }); + + test('includes selectedNetwork in onChange event when coBadgedSupport is enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + selectedNetwork: undefined, // Initially undefined + }) + ); + }); + }); + + test('does not include selectedNetwork in onChange event when coBadgedSupport is not enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.not.objectContaining({ + selectedNetwork: expect.anything(), + }) + ); + }); + }); + + test('resets selectedNetwork when binInfo is cleared', async () => { + const onChange = jest.fn(); + + // Start with binInfo, then clear it + const mockUseBinLookup = jest.spyOn(useBinLookupModule, 'useBinLookup'); + mockUseBinLookup.mockReturnValueOnce({ binInfo: mockBinInfo }); + + const { rerender } = render( + + + + ); + + // Clear binInfo + mockUseBinLookup.mockReturnValue({ binInfo: undefined }); + + rerender( + + + + ); + + // Should trigger onChange with selectedNetwork reset + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + selectedNetwork: undefined, + }) + ); + }); + }); + }); + + describe('Bin Lookup', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfo = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => mockBinInfo, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('includes binInfo in onChange event when binLookup is enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242'); // 6 digits to trigger bin lookup + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + binInfo: expect.objectContaining({ + brand: 'visa', + funding: 'debit', + issuer: expect.objectContaining({ + name: 'Test Bank', + }), + }), + }) + ); + }); + }); + + test('does not include binInfo in onChange event when binLookup is disabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.not.objectContaining({ + binInfo: expect.anything(), + }) + ); + }); + }); + + test('makes API call to correct endpoint for bin lookup', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242424242424242'); // Full card number + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.basistheory.com/enrichments/card-details?bin=424242', + { + method: 'GET', + headers: { + 'BT-API-KEY': 'test-api-key', + }, + } + ); + }); + }); + + test('handles bin lookup API errors gracefully', async () => { + const onChange = jest.fn(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + (global.fetch as jest.Mock).mockRejectedValue(new Error('API Error')); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '424242424242424242'); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('BIN lookup failed:', expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + + test('caches bin lookup results to avoid duplicate API calls', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + + // First call + fireEvent.changeText(el, '424242424242424242'); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + // Clear and enter same BIN again + fireEvent.changeText(el, ''); + fireEvent.changeText(el, '424242424242424242'); + + // Should not make another API call due to caching + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + test('only triggers bin lookup for 6-digit BINs', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + + // Less than 6 digits - should not trigger lookup + fireEvent.changeText(el, '42424'); + expect(global.fetch).not.toHaveBeenCalled(); + + // Exactly 6 digits - should trigger lookup + fireEvent.changeText(el, '424242'); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Combined Co-badge Support and Bin Lookup', () => { + const mockBt = { + config: { + apiKey: 'test-api-key', + apiBaseUrl: 'https://api.basistheory.com', + }, + proxy: jest.fn(), + sessions: { + create: jest.fn(), + }, + tokenIntents: { + create: jest.fn(), + delete: jest.fn(), + }, + tokens: { + getById: jest.fn(), + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + tokenize: jest.fn(), + encrypt: jest.fn(), + }, + }; + + const mockBinInfoWithCoBadge = { + brand: 'visa', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + segment: 'consumer', + additional: [ + { + brand: 'cartes-bancaires', + funding: 'debit', + issuer: { + country: 'US', + name: 'Test Bank', + }, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(useBinLookupModule, 'useBinLookup').mockReturnValue({ + binInfo: mockBinInfoWithCoBadge, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('includes both binInfo and selectedNetwork when both features are enabled', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const el = screen.getByPlaceholderText('Card Number'); + fireEvent.changeText(el, '4242424242424242'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + binInfo: expect.objectContaining({ + brand: 'visa', + additional: expect.arrayContaining([ + expect.objectContaining({ + brand: 'cartes-bancaires', + }), + ]), + }), + selectedNetwork: undefined, // Initially undefined + }) + ); + }); + }); + }); }); diff --git a/tests/hooks/useBrandSelector.test.tsx b/tests/hooks/useBrandSelector.test.tsx new file mode 100644 index 0000000..a51352d --- /dev/null +++ b/tests/hooks/useBrandSelector.test.tsx @@ -0,0 +1,239 @@ +import { renderHook } from '@testing-library/react-native'; +import { useBrandSelector } from '../../src/components/shared/useBrandSelector'; +import { CardBrand, CoBadgedSupport } from '../../src/CardElementTypes'; +import type { BinInfo } from '../../src/CardElementTypes'; + +describe('useBrandSelector', () => { + const mockSetSelectedNetwork = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const defaultProps = { + binInfo: undefined, + coBadgedSupport: undefined, + selectedNetwork: undefined, + setSelectedNetwork: mockSetSelectedNetwork, + }; + + + describe('brandSelectorOptions', () => { + it('should return empty array when binInfo is undefined', () => { + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: undefined, + }) + ); + + expect(result.current.brandSelectorOptions).toEqual([]); + }); + + it('should return options with main brand when binInfo exists', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [], + }; + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + }) + ); + + expect(result.current.brandSelectorOptions).toContain('visa'); + }); + + it('should include additional brands that are in coBadgedSupport', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'cartes-bancaires', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + expect(options).toContain('cartes-bancaires'); + expect(options).toContain('visa'); + }); + + it('should skip additional brands without brand property', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'cartes-bancaires', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + { brand: 'discover', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + expect(options).toHaveLength(2); // cartes-bancaires, visa + }); + + it('should return unique brands (no duplicates)', () => { + const mockBinInfo = { + brand: 'visa', + funding: 'credit', + issuer: { name: 'Test Bank', country: 'US' }, + segment: 'consumer', + additional: [ + { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, // Duplicate of main brand + { brand: 'mastercard', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' } }, + ], + }; + + + const { result } = renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }) + ); + + const options = result.current.brandSelectorOptions; + const visaCount = options.filter(brand => brand === 'visa').length; + expect(visaCount).toBe(1); // Should only appear once + }); + }); + + describe('selectedNetwork reset effect', () => { + it('should call setSelectedNetwork with undefined when binInfo becomes undefined and selectedNetwork exists', () => { + const { rerender } = renderHook( + ({ binInfo, selectedNetwork }) => + useBrandSelector({ + ...defaultProps, + binInfo, + selectedNetwork, + }), + { + initialProps: { + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] } as BinInfo | undefined, + selectedNetwork: 'visa' as CardBrand, + }, + } + ); + + // Clear binInfo + rerender({ + binInfo: undefined, + selectedNetwork: 'visa' as CardBrand, + }); + + expect(mockSetSelectedNetwork).toHaveBeenCalledWith(undefined); + }); + + it('should not call setSelectedNetwork when binInfo is undefined but selectedNetwork is also undefined', () => { + renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: undefined, + selectedNetwork: undefined, + }) + ); + + expect(mockSetSelectedNetwork).not.toHaveBeenCalled(); + }); + + it('should not call setSelectedNetwork when binInfo exists', () => { + renderHook(() => + useBrandSelector({ + ...defaultProps, + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + selectedNetwork: 'visa' as CardBrand, + }) + ); + + expect(mockSetSelectedNetwork).not.toHaveBeenCalled(); + }); + }); + + describe('memoization', () => { + it('should memoize brandSelectorOptions when dependencies do not change', () => { + const mockBinInfo = { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }; + + const { result, rerender } = renderHook( + ({ binInfo, coBadgedSupport }) => + useBrandSelector({ + ...defaultProps, + binInfo, + coBadgedSupport, + }), + { + initialProps: { + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }, + } + ); + + const firstResult = result.current.brandSelectorOptions; + + // Rerender with same props + rerender({ + binInfo: mockBinInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }); + + const secondResult = result.current.brandSelectorOptions; + + expect(firstResult).toEqual(secondResult); // Should have the same content + }); + + it('should recalculate brandSelectorOptions when binInfo changes', () => { + const { result, rerender } = renderHook( + ({ binInfo }) => + useBrandSelector({ + ...defaultProps, + binInfo, + coBadgedSupport: [CoBadgedSupport.CartesBancaires], + }), + { + initialProps: { + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + }, + } + ); + + const firstResult = result.current.brandSelectorOptions; + + // Change binInfo + rerender({ + binInfo: { brand: 'visa', funding: 'credit', issuer: { name: 'Test Bank', country: 'US' }, segment: 'consumer', additional: [] }, + }); + + const secondResult = result.current.brandSelectorOptions; + + expect(firstResult).not.toBe(secondResult); // Should be different references + }); + }); +}); \ No newline at end of file