From 3ca626a4c88b036944e77eb30f30e2cebe758645 Mon Sep 17 00:00:00 2001 From: Diego Diestra Date: Tue, 2 Dec 2025 18:37:09 -0500 Subject: [PATCH 1/2] feat: filter bin info based on user input --- src/CardElementTypes.ts | 39 ++++++++------- src/components/CardNumberElement.hook.ts | 2 +- src/components/shared/useBrandSelector.ts | 4 +- src/components/useBinLookup.ts | 55 ++++++++++++++++++--- src/utils/shared.ts | 15 ------ tests/components/CardNumberElement.test.tsx | 15 ++++++ 6 files changed, 89 insertions(+), 41 deletions(-) diff --git a/src/CardElementTypes.ts b/src/CardElementTypes.ts index 96a5c12..f3c8a73 100644 --- a/src/CardElementTypes.ts +++ b/src/CardElementTypes.ts @@ -32,24 +32,29 @@ export const CARD_BRANDS = [ export type CardBrand = (typeof CARD_BRANDS)[number]; export enum CoBadgedSupport { - CartesBancaires = 'cartes-bancaires', + CartesBancaires = 'cartes-bancaires', } +interface BinRange { + binMin: string; + binMax: string; +} 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[]; - } + country: string; + name: string; +} +interface CardInfo { + brand: string; + funding: string; + issuer: CardIssuerDetails; + binRange?: BinRange[]; +} +export interface BinInfo { + brand?: string; + funding?: string; + issuer?: CardIssuerDetails; + segment?: string; + additional?: CardInfo[]; + binRange?: BinRange[]; +} \ No newline at end of file diff --git a/src/components/CardNumberElement.hook.ts b/src/components/CardNumberElement.hook.ts index 24065f6..56e92aa 100644 --- a/src/components/CardNumberElement.hook.ts +++ b/src/components/CardNumberElement.hook.ts @@ -58,7 +58,7 @@ export const useCardNumberElement = ({ const [selectedNetwork, setSelectedNetwork] = useState(undefined); const binEnabled = binLookup || hasCoBadgedSupport; - const { binInfo } = useBinLookup(binEnabled, elementValue.replaceAll(' ', '').slice(0, 6)); + const { binInfo } = useBinLookup(binEnabled, elementValue.replaceAll(' ', '')); // Get brand options from useBrandSelector hook const { brandSelectorOptions } = useBrandSelector({ diff --git a/src/components/shared/useBrandSelector.ts b/src/components/shared/useBrandSelector.ts index 27b12cd..b241a58 100644 --- a/src/components/shared/useBrandSelector.ts +++ b/src/components/shared/useBrandSelector.ts @@ -32,7 +32,9 @@ export const useBrandSelector = ({ const { brand, additional } = binInfo; const brandOptions = new Set(); - brandOptions.add(convertApiBrandToBrand(brand)); + if (brand) { + brandOptions.add(convertApiBrandToBrand(brand)); + } additional?.forEach((a) => { if (!a.brand) return; diff --git a/src/components/useBinLookup.ts b/src/components/useBinLookup.ts index d99564d..67764a9 100644 --- a/src/components/useBinLookup.ts +++ b/src/components/useBinLookup.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import { _useConfigManager, } from '../BasisTheoryProvider'; @@ -26,14 +26,55 @@ export const getBinInfo = async ( return (data as BinInfo) || undefined; }; -export const useBinLookup = (enabled: boolean, bin: string) => { - const [binInfo, setBinInfo] = useState(undefined); +export const useBinLookup = (enabled: boolean, cardValue: string) => { + const [rawBinInfo, setRawBinInfo] = useState(undefined); const lastBinRef = useRef(undefined); const cache = useRef>(new Map()); + const binInfo = useMemo(() => { + if (!rawBinInfo || !cardValue) { + return undefined; + } + + const primaryRanges = rawBinInfo.binRange || []; + + const isValidPrimaryRange = primaryRanges?.some((range) => { + const binLength = Math.min(range.binMin.length, cardValue.length); + const cardBin = parseInt(cardValue?.slice(0, binLength)); + const binMin = parseInt(range.binMin?.slice(0, binLength)); + const binMax = parseInt(range.binMax?.slice(0, binLength)); + return binMin <= cardBin && cardBin <= binMax; + }); + + const additionals = rawBinInfo.additional?.filter((additional) => { + const ranges = additional.binRange; + return ranges?.some((range) => { + const binLength = Math.min(range.binMin.length, cardValue.length); + const cardBin = parseInt(cardValue?.slice(0, binLength)); + const binMin = parseInt(range.binMin?.slice(0, binLength)); + const binMax = parseInt(range.binMax?.slice(0, binLength)); + return binMin <= cardBin && cardBin <= binMax; + }); + }) || []; + + + if (!isValidPrimaryRange && !additionals.length) { + return undefined; + } + + return { + ...(isValidPrimaryRange ? { ...rawBinInfo, binRange: undefined } : {}), + additional: additionals.map((additional) => ({ + ...additional, + binRange: undefined, + })), + }; + }, [rawBinInfo, cardValue]); + useEffect(() => { + const bin = cardValue?.slice(0, 6); if (!enabled || !bin || bin.length !== 6) { - setBinInfo(undefined); + setRawBinInfo(undefined); lastBinRef.current = undefined; return; } @@ -44,14 +85,14 @@ export const useBinLookup = (enabled: boolean, bin: string) => { const fetchBinInfo = async () => { if (cache.current.has(bin)) { - setBinInfo(cache.current.get(bin)); + setRawBinInfo(cache.current.get(bin)); lastBinRef.current = bin; return; } try { const result = await getBinInfo(bin); - setBinInfo(result); + setRawBinInfo(result); cache.current.set(bin, result); lastBinRef.current = bin; } catch (err: unknown) { @@ -62,7 +103,7 @@ export const useBinLookup = (enabled: boolean, bin: string) => { }; fetchBinInfo(); - }, [bin, enabled]); + }, [cardValue, enabled]); return { binInfo }; }; diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 3f1735b..17ae308 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -34,21 +34,6 @@ const filterOutMaxOccurrences = (numbers: number[]) => 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'; }; diff --git a/tests/components/CardNumberElement.test.tsx b/tests/components/CardNumberElement.test.tsx index a66b998..7c69e45 100644 --- a/tests/components/CardNumberElement.test.tsx +++ b/tests/components/CardNumberElement.test.tsx @@ -412,6 +412,12 @@ describe('CardNumberElement', () => { country: 'US', name: 'Test Bank', }, + binRange: [ + { + binMin: '424242', + binMax: '424242', + }, + ], segment: 'consumer', additional: [ { @@ -421,6 +427,12 @@ describe('CardNumberElement', () => { country: 'US', name: 'Test Bank', }, + binRange: [ + { + binMin: '424242', + binMax: '424242', + }, + ], }, ], }; @@ -612,6 +624,9 @@ describe('CardNumberElement', () => { country: 'US', name: 'Test Bank', }, + binRange: [ + { binMin: '424242', binMax: '424242' }, + ], segment: 'consumer', additional: [], }; From 2c799b762129597f592bf2a0cadb35ea1357cb2b Mon Sep 17 00:00:00 2001 From: Diego Diestra Date: Wed, 10 Dec 2025 11:30:42 -0500 Subject: [PATCH 2/2] chore: simplify functions --- src/CardElementTypes.ts | 2 +- src/components/useBinLookup.ts | 35 ++++++++++++++++------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/CardElementTypes.ts b/src/CardElementTypes.ts index f3c8a73..1aede54 100644 --- a/src/CardElementTypes.ts +++ b/src/CardElementTypes.ts @@ -35,7 +35,7 @@ export enum CoBadgedSupport { CartesBancaires = 'cartes-bancaires', } -interface BinRange { +export interface BinRange { binMin: string; binMax: string; } diff --git a/src/components/useBinLookup.ts b/src/components/useBinLookup.ts index 67764a9..de7cf28 100644 --- a/src/components/useBinLookup.ts +++ b/src/components/useBinLookup.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useMemo } from 'react'; import { _useConfigManager, } from '../BasisTheoryProvider'; -import type { BinInfo } from '../CardElementTypes'; +import type { BinInfo, BinRange } from '../CardElementTypes'; export const getBinInfo = async ( bin: string @@ -25,6 +25,14 @@ export const getBinInfo = async ( const data = await response.json(); return (data as BinInfo) || undefined; }; + +const isCardInBinRange = (range: BinRange, cardValue: string) => { + const binLength = Math.min(range.binMin.length, cardValue.length); + const cardBin = Number.parseInt(cardValue?.slice(0, binLength)); + const binMin = Number.parseInt(range.binMin?.slice(0, binLength)); + const binMax = Number.parseInt(range.binMax?.slice(0, binLength)); + return binMin <= cardBin && cardBin <= binMax; +}; export const useBinLookup = (enabled: boolean, cardValue: string) => { const [rawBinInfo, setRawBinInfo] = useState(undefined); @@ -38,33 +46,22 @@ export const useBinLookup = (enabled: boolean, cardValue: string) => { const primaryRanges = rawBinInfo.binRange || []; - const isValidPrimaryRange = primaryRanges?.some((range) => { - const binLength = Math.min(range.binMin.length, cardValue.length); - const cardBin = parseInt(cardValue?.slice(0, binLength)); - const binMin = parseInt(range.binMin?.slice(0, binLength)); - const binMax = parseInt(range.binMax?.slice(0, binLength)); - return binMin <= cardBin && cardBin <= binMax; - }); + const isValidPrimaryRange = primaryRanges?.some((range) => + isCardInBinRange(range, cardValue) + ); const additionals = rawBinInfo.additional?.filter((additional) => { const ranges = additional.binRange; - return ranges?.some((range) => { - const binLength = Math.min(range.binMin.length, cardValue.length); - const cardBin = parseInt(cardValue?.slice(0, binLength)); - const binMin = parseInt(range.binMin?.slice(0, binLength)); - const binMax = parseInt(range.binMax?.slice(0, binLength)); - return binMin <= cardBin && cardBin <= binMax; - }); - }) || []; - + return ranges?.some((range) => isCardInBinRange(range, cardValue)); + }); - if (!isValidPrimaryRange && !additionals.length) { + if (!isValidPrimaryRange && !additionals?.length) { return undefined; } return { ...(isValidPrimaryRange ? { ...rawBinInfo, binRange: undefined } : {}), - additional: additionals.map((additional) => ({ + additional: additionals?.map((additional) => ({ ...additional, binRange: undefined, })),